diff --git a/docs/AGE_PROFILES.md b/docs/AGE_PROFILES.md index 6b41e3f..77ef083 100644 --- a/docs/AGE_PROFILES.md +++ b/docs/AGE_PROFILES.md @@ -40,6 +40,39 @@ profiles, not claims that Legacy had identically named terrain symbols. Every family retains a safe cobblestone arrival platform. An omitted terrain page still uses the deterministic seed-selected profile. +The separate **Floating Islands** feature page adds rare high-altitude island +clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does +not replace the terrain page. The feature is seeded by the Age and saved with the written +plan, so revisiting an Age recreates the same islands. Without a preceding +Block page, the Age seed chooses one valid full-collision block from the Block +Pages pool for the island body. A full-block material page immediately before +Floating Islands overrides that choice. Ordinary surface biomes still get +dirt/grass top layers; Nether and End biomes use the chosen block throughout. +The feature is additive even on Sky Islands terrain, +which already forms its own islands. Cave terrain rejects it. A page-free random +Age may also roll Floating Islands as one of its material features. + +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 +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 +writing. Each unpaired material gets its own compatible feature, and repeated +decorations use separate placement rolls. Repeated unmodified feature pages +choose different compatible materials until their material pool is exhausted. + +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 +verify the elevated clusters appear above its unchanged base terrain. Reopen +the same Age to check that island positions stay fixed. Islands should be less +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. + ## Written biome selection (generation version 16) Biome pages follow the terrain page and precede the distribution-control page @@ -72,8 +105,15 @@ also accepted. Other cave/biome pairings are diagnosed and blocked for now. Milestone 6 instability should allow such illegal combinations as unstable Ages, without silently treating them as ordinary cave generation. Flat terrain uses Overworld choices when a distribution is authored; its Native default -remains Plains. Void terrain defaults to The Void and needs additional authored -biomes for a multi-biome distribution. +remains Plains. Void terrain defaults to The Void when no biome page is written, +but accepts authored biome pages. One authored biome uses Single distribution +when no controller is written. An explicit multi-biome distribution needs enough +authored biomes; The Void is not silently added to fill missing positions. These +biome regions exist even without blocks or a Floating Islands page, so biome- +dependent weather and optional season behavior can still vary there. To check +this in-game, bind Void terrain with a Desert Biome page and no feature page: +the Age should be empty while F3 reports Desert. A Void book without biome +pages should instead report The Void. The resolved list of biome IDs, their order/repetitions, and distribution are frozen into the Age plan. A removed biome ID emits a warning and uses an diff --git a/docs/DESCRIPTIVE_BOOK_PRESENTATION.md b/docs/DESCRIPTIVE_BOOK_PRESENTATION.md index da15e99..43a9c48 100644 --- a/docs/DESCRIPTIVE_BOOK_PRESENTATION.md +++ b/docs/DESCRIPTIVE_BOOK_PRESENTATION.md @@ -4,13 +4,13 @@ The Legacy-style front spread has the Age name and author on the left and the de The Binder records the taking player's name on the finished book; direct Age binding also records the binding player. Older books without this component display `Unknown author` rather than attributing them to whoever happens to open them. Link-Panel-only and older page-free books retain their random-Age glyph details and destination preview. On authored books, hovering the front-page name exposes the existing trait summary without filling the left page with text. -The server resolves symbol display metadata from its current catalog and sends advisory grammar diagnostics with the book-opening payload. A missing data-pack symbol draws the unresolved page artwork and its original ID. An affected page displays a red `!` with the server's diagnostic on hover; separate writing-note pages show all diagnostics, including incomplete branches that the Age compiler filled. These UI notices do not alter or recompile the persisted Age plan. Symbol pages use the same bounded, runtime glyph composition as Page items and do not require Create, Ponder, or Immersive Portals. +The server resolves symbol display metadata from its current catalog. A missing data-pack symbol draws the unresolved page artwork and its original ID. Descriptive Books do not show writing notes or grammar warning markers; grammar validation remains in the Binder and on the server when an Age is created. Opening a book never changes its persisted Age plan. Symbol pages use the same bounded, runtime glyph composition as Page items and do not require Create, Ponder, or Immersive Portals. ## Manual visual and behavior checks 1. Bind a Descriptive Book with two distinct symbol pages. Open it: the front spread shows its Age name, your player name, and the link panel. Turn twice and confirm exactly one large symbol on each right page, in authored order, with no plain-text symbol description or blank trailing page. 2. Hover the front-page name to confirm the trait summary remains accessible. Click the link panel to confirm normal travel; repeat from a lectern to check the name, panel, and page-turn layout. 3. Open a Link-Panel-only book and confirm its existing random-Age details and destination preview still appear. -4. In a copy of a test world, remove a data pack that supplied one authored symbol. Reopen the book: its original symbol ID should remain readable beside the unresolved artwork, and the writing notes should explain the missing definition. Restore the pack and verify the page artwork returns without changing the saved Age. +4. In a copy of a test world, remove a data pack that supplied one authored symbol. Reopen the book: its original symbol ID should remain readable beside the unresolved artwork, with no writing-notes pages. Restore the pack and verify the page artwork returns without changing the saved Age. `./gradlew build --offline` verifies compilation and jar assembly. The visual and data-pack-removal checks require an in-game client. diff --git a/gradle.properties b/gradle.properties index 902cb0c..3e586bf 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.10.1 +mod_version=0.4.11.7 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/AgeBiomeSelection.java b/src/main/java/net/dnorberg/rivencraft/book/AgeBiomeSelection.java index 78687e3..3b271e4 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeBiomeSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeBiomeSelection.java @@ -48,7 +48,8 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List 1)) { distribution = authored.size() == 1 ? AgeTraits.BiomeDistribution.SINGLE : authored.size() == 2 ? AgeTraits.BiomeDistribution.TILED @@ -87,7 +88,9 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List filler = new ArrayList<>(availableBiomes); + // An authored Void Age must not acquire The Void as an unrequested second biome. + List filler = profile.terrain() == AgeRuntimeProfile.Terrain.VOID && !authored.isEmpty() + ? new ArrayList<>() : new ArrayList<>(availableBiomes); java.util.Collections.shuffle(filler, new Random(seed ^ 0x42494F4D45504F4FL)); for (ResourceLocation id : filler) { if (distinct.size() >= target) break; diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java index 4eac852..550fdff 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java @@ -1,6 +1,7 @@ package net.dnorberg.rivencraft.book; import java.util.Objects; +import java.util.List; import java.util.UUID; import net.dnorberg.rivencraft.Rivencraft; import net.minecraft.core.BlockPos; @@ -19,7 +20,8 @@ public record AgeDefinition( BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan, - @Nullable AgeSeasonMode seasonMode + @Nullable AgeSeasonMode seasonMode, + List randomFeatures ) { public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath( Rivencraft.MOD_ID, "random_vanilla" @@ -32,6 +34,10 @@ public record AgeDefinition( Objects.requireNonNull(generationProfile, "generationProfile"); Objects.requireNonNull(dimensionId, "dimensionId"); Objects.requireNonNull(traits, "traits"); + randomFeatures = List.copyOf(randomFeatures); + if (plan != null && !randomFeatures.isEmpty()) { + throw new IllegalArgumentException("Authored Ages cannot also have random features"); + } if (plan != null && plan.ageSeed() != seed) { throw new IllegalArgumentException("Age plan seed does not match its Age definition"); } @@ -41,14 +47,22 @@ public record AgeDefinition( int generationVersion, long createdGameTime, ResourceLocation dimensionId, BlockPos spawn, AgeTraits traits) { this(id, name, seed, generationProfile, generationVersion, createdGameTime, - dimensionId, spawn, traits, null, null); + dimensionId, spawn, traits, null, null, List.of()); } public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile, int generationVersion, long createdGameTime, ResourceLocation dimensionId, BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan) { this(id, name, seed, generationProfile, generationVersion, createdGameTime, - dimensionId, spawn, traits, plan, null); + dimensionId, spawn, traits, plan, null, List.of()); + } + + public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile, + int generationVersion, long createdGameTime, ResourceLocation dimensionId, + BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan, + @Nullable AgeSeasonMode seasonMode) { + this(id, name, seed, generationProfile, generationVersion, createdGameTime, + dimensionId, spawn, traits, plan, seasonMode, List.of()); } public static ResourceLocation dimensionId(UUID ageId) { @@ -79,6 +93,6 @@ public record AgeDefinition( public AgeDefinition withSpawn(BlockPos spawn) { return new AgeDefinition(id, name, seed, generationProfile, generationVersion, - createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode); + createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode, randomFeatures); } } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java index cbbf464..23118be 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -302,6 +302,8 @@ public final class AgeDimensionService { private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) { AgeRuntimeProfile profile = runtimeProfile(age); BiomeSource biomeSource = createBiomeSource(server, age, profile); + List features = age.plan() == null + ? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan()); if (profile.terrain() == AgeRuntimeProfile.Terrain.FLAT) { var settings = FlatLevelGeneratorSettings.getDefault( server.registryAccess().lookupOrThrow(Registries.BIOME), @@ -309,7 +311,7 @@ public final class AgeDimensionService { server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE) ); return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION - ? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings); + ? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed()); } if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) { FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings( @@ -319,7 +321,7 @@ public final class AgeDimensionService { ); settings.updateLayers(); return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION - ? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings); + ? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed()); } var noiseSettings = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); @@ -327,7 +329,6 @@ public final class AgeDimensionService { Registries.NOISE_SETTINGS, profile.noiseSettings()); NoiseGeneratorSettings baseSettings = noiseSettings.getOrThrow(settingsKey).value(); Holder settings = Holder.direct(withBiomeMaterialRules(baseSettings)); - List features = AgeFeatureSelection.resolved(age.plan()); return features.isEmpty() ? new NoiseBasedChunkGenerator(biomeSource, settings) : new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed()); } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java index 4fb1e0b..e65052c 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java @@ -3,8 +3,12 @@ package net.dnorberg.rivencraft.book; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Random; import net.dnorberg.rivencraft.writing.BlockSymbolPages; import net.dnorberg.rivencraft.writing.SymbolSemantics; import net.dnorberg.rivencraft.writing.WritingGrammar; @@ -20,6 +24,10 @@ 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")); + private static final List FLUID_FEATURES = List.of( + id("surface_lakes"), id("deep_lakes"), id("lava_lakes")); + private AgeFeatureSelection() {} public record Choice(ResourceLocation id, Optional material) { @@ -34,9 +42,9 @@ public final class AgeFeatureSelection { } public static List compile(WritingGrammar.Result writing, AgeRuntimeProfile profile, - AgeBiomeSelection biomes, RegistryAccess registries) { + AgeBiomeSelection biomes, RegistryAccess registries, long ageSeed) { List choices = choices(writing.symbols().stream() - .map(symbol -> symbol.semantics()).toList()); + .map(symbol -> symbol.semantics()).toList(), profile, ageSeed); for (Choice choice : choices) { String error = error(choice.id(), profile, registries); if (error == null) error = materialError(choice); @@ -50,10 +58,15 @@ public final class AgeFeatureSelection { return choices; } - private static List choices(List symbols) { + private static List choices(List symbols, AgeRuntimeProfile profile, long ageSeed) { List choices = new ArrayList<>(); ResourceLocation pendingMaterial = null; - for (SymbolSemantics semantics : symbols) { + for (int index = 0; index < symbols.size(); index++) { + SymbolSemantics semantics = symbols.get(index); + if (pendingMaterial != null && semantics.role() != SymbolSemantics.Role.FEATURE) { + choices.add(implied(pendingMaterial, profile, ageSeed, index - 1)); + pendingMaterial = null; + } if (semantics.role() == SymbolSemantics.Role.BLOCK) { pendingMaterial = ((SymbolSemantics.ResourceValue) semantics.value()).id(); } else if (semantics.role() == SymbolSemantics.Role.FEATURE) { @@ -62,29 +75,114 @@ public final class AgeFeatureSelection { pendingMaterial = null; } } + if (pendingMaterial != null) choices.add(implied(pendingMaterial, profile, ageSeed, symbols.size() - 1)); return List.copyOf(choices); } + private static Choice implied(ResourceLocation material, AgeRuntimeProfile profile, long ageSeed, int index) { + List candidates = compatibleFeatures(material, profile); + if (candidates.isEmpty()) throw new IllegalArgumentException("No compatible feature for material " + + material + " on " + profile.terrain() + " terrain"); + long mixed = mix(ageSeed ^ (long) material.hashCode() * 0x9E3779B97F4A7C15L ^ index); + return new Choice(candidates.get(Math.floorMod(mixed, candidates.size())), Optional.of(material)); + } + + public static boolean supportsUnpairedMaterials(WritingGrammar.Result writing, AgeRuntimeProfile profile) { + List symbols = writing.symbols().stream().map(symbol -> symbol.semantics()).toList(); + ResourceLocation pending = null; + for (SymbolSemantics symbol : symbols) { + if (pending != null && symbol.role() != SymbolSemantics.Role.FEATURE) { + if (compatibleFeatures(pending, profile).isEmpty()) return false; + pending = null; + } + if (symbol.role() == SymbolSemantics.Role.BLOCK) + pending = ((SymbolSemantics.ResourceValue) symbol.value()).id(); + else if (symbol.role() == SymbolSemantics.Role.FEATURE) pending = null; + } + return pending == null || !compatibleFeatures(pending, profile).isEmpty(); + } + + private static List compatibleFeatures(ResourceLocation material, AgeRuntimeProfile profile) { + var block = BuiltInRegistries.BLOCK.getOptional(material); + if (block.isEmpty()) return List.of(); + List candidates = BlockSymbolPages.isFluidMaterial(block.orElseThrow()) + ? FLUID_FEATURES : BlockSymbolPages.isRockMaterial(block.orElseThrow()) + ? BLOCK_FEATURES : List.of(); + return candidates.stream().filter(id -> compatible(id, profile)).toList(); + } + + private static boolean compatible(ResourceLocation feature, AgeRuntimeProfile profile) { + if (AgeBiomeSelection.isCave(profile)) return false; + return floatingIslands(feature) || profile.terrain() == AgeRuntimeProfile.Terrain.NOISE; + } + public static List resolved(AgeGenerationPlan plan) { if (plan == null) return List.of(); - return choices(plan.symbols().stream().map(symbol -> symbol.semantics()).toList()).stream() - .map(choice -> choice.material().isPresent() || !isRock(choice.id()) && !isLake(choice.id()) - ? choice : new Choice(choice.id(), Optional.of(randomMaterial(plan.ageSeed(), choice.id())))) - .toList(); + Map> materialOrders = new HashMap<>(); + Map occurrences = new HashMap<>(); + List resolved = new ArrayList<>(); + for (Choice choice : choices(plan.symbols().stream().map(symbol -> symbol.semantics()).toList(), + plan.runtimeProfile(), plan.ageSeed())) { + if (choice.material().isPresent() + || !isRock(choice.id()) && !isLake(choice.id()) && !floatingIslands(choice.id())) { + resolved.add(choice); + continue; + } + List materials = materialOrders.computeIfAbsent(choice.id(), + id -> shuffledMaterials(plan.ageSeed(), id)); + int occurrence = occurrences.merge(choice.id(), 1, Integer::sum) - 1; + resolved.add(new Choice(choice.id(), Optional.of(materials.get(occurrence % materials.size())))); + } + return List.copyOf(resolved); } - private static ResourceLocation randomMaterial(long ageSeed, ResourceLocation feature) { - var category = isRock(feature) ? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY; - List pool = BlockSymbolPages.availableBlocks(category).stream() - .filter(id -> materialError(new Choice(feature, Optional.of(id))) == null).toList(); + /** Page-free Ages roll up to two compatible material/feature pairs once at creation. */ + public static List randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries) { + Random random = new Random(ageSeed ^ 0x4D4154455249414CL); + int count = random.nextInt(3); + List features = new ArrayList<>(); + for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null) features.add(id); + for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null) features.add(id); + Collections.shuffle(features, random); + List choices = new ArrayList<>(); + for (ResourceLocation feature : features) { + if (choices.size() >= count) break; + var category = isRock(feature) || floatingIslands(feature) + ? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY; + List materials = BlockSymbolPages.availableBlocks(category).stream() + .filter(id -> materialError(new Choice(feature, Optional.of(id))) == null).toList(); + if (!materials.isEmpty()) choices.add(new Choice(feature, + Optional.of(materials.get(random.nextInt(materials.size()))))); + } + return List.copyOf(choices); + } + + private static List shuffledMaterials(long ageSeed, ResourceLocation feature) { + var category = isRock(feature) || floatingIslands(feature) + ? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY; + List pool = new ArrayList<>(BlockSymbolPages.availableBlocks(category).stream() + .filter(id -> materialError(new Choice(feature, Optional.of(id))) == null).toList()); if (pool.isEmpty()) throw new IllegalStateException("No valid material blocks for " + feature); - long mixed = ageSeed ^ (long) feature.hashCode() * 0x9E3779B97F4A7C15L; - mixed = (mixed ^ mixed >>> 30) * 0xBF58476D1CE4E5B9L; - mixed = (mixed ^ mixed >>> 27) * 0x94D049BB133111EBL; - return pool.get(Math.floorMod(mixed ^ mixed >>> 31, pool.size())); + Collections.shuffle(pool, new Random(mix(ageSeed ^ (long) feature.hashCode() * 0x9E3779B97F4A7C15L))); + return pool; + } + + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } + + private static ResourceLocation id(String path) { + return ResourceLocation.fromNamespaceAndPath("rivencraft", path); } public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) { + if (floatingIslands(choice)) { + if (AgeBiomeSelection.isCave(profile)) + return "Feature " + choice + " cannot form above Cave terrain"; + return null; + } if (caves(choice) || ravines(choice)) { if (profile.terrain() != AgeRuntimeProfile.Terrain.NOISE) { return "Feature " + choice + " requires noise terrain with stone to carve; not Flat or Void"; @@ -118,7 +216,7 @@ public final class AgeFeatureSelection { if (block.isEmpty() || block.orElseThrow() == Blocks.AIR) { return "Feature " + choice.id() + " references missing block material " + material; } - if (isRock(choice.id())) { + if (isRock(choice.id()) || floatingIslands(choice.id())) { if (!BlockSymbolPages.isRockMaterial(block.orElseThrow())) { return "Feature " + choice.id() + " needs a full solid block without a block entity: " + material; } @@ -154,6 +252,10 @@ public final class AgeFeatureSelection { return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "ravines")); } + public static boolean floatingIslands(ResourceLocation choice) { + return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "floating_islands")); + } + 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/AgeGenerationPlan.java b/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java index bf69259..c8018cc 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationPlan.java @@ -65,7 +65,7 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin AgeRuntimeProfile profile = AgeRuntimeProfiles.profiles().get(selected); if (profile == null) throw new IllegalArgumentException("Unsupported Age terrain profile " + selected); AgeBiomeSelection biomes = AgeBiomeSelection.compile(parsed, traits, profile, ageSeed, registries); - AgeFeatureSelection.compile(parsed, profile, biomes, registries); + AgeFeatureSelection.compile(parsed, profile, biomes, registries, ageSeed); AgeGenerationPlan plan = new AgeGenerationPlan(CURRENT_VERSION, ageSeed, writing, parsed.symbols().stream().map(symbol -> new ResolvedSymbol( symbol.index(), symbol.reference(), symbol.semantics())).toList(), diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java index f062592..b73e480 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java @@ -66,7 +66,22 @@ public final class AgePersistenceGameTests { new AgeReference(UUID.randomUUID(), "Panel Only"), new OrderedWriting(List.of(WritingEntry.linkPanel()))); helper.assertTrue(pageFree.plan() == null && panelOnly.plan() == null, - "Old and Link-Panel-only books must retain the random Age path"); + "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, + "A random Age may roll at most two material features"); + for (AgeFeatureSelection.Choice choice : age.randomFeatures()) { + helper.assertTrue(choice.material().isPresent() + && AgeFeatureSelection.materialError(choice) == null, + "Random features must each carry a valid block or fluid material"); + } + } + AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel()); + var restored = AgeRegistrySavedData.load( + registry.save(new CompoundTag(), helper.getLevel().registryAccess()), + helper.getLevel().registryAccess()); + helper.assertValueEqual(restored.find(pageFree.id()).orElseThrow().randomFeatures(), + pageFree.randomFeatures(), "Random material features must survive a saved-data round trip"); helper.succeed(); } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java index 11c479d..9d81134 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java @@ -33,6 +33,7 @@ public final class AgeRegistrySavedData extends SavedData { private static final String SPAWN_Z_TAG = "SpawnZ"; private static final String TRAITS_TAG = "Traits"; private static final String PLAN_TAG = "GenerationPlan"; + private static final String RANDOM_FEATURES_TAG = "RandomFeatures"; private static final String SEASON_MODE_TAG = "SeasonMode"; private static final String BIOME_DISTRIBUTION_TAG = "BiomeDistribution"; private static final String LIGHTING_TAG = "Lighting"; @@ -133,7 +134,9 @@ public final class AgeRegistrySavedData extends SavedData { null, AgeEnvironmentCompiler.apply(plan, traits), plan, - AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")) + AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")), + plan == null ? AgeFeatureSelection.randomChoices(seed, + AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of() ); ages.put(created.id(), created); setDirty(); @@ -179,7 +182,8 @@ public final class AgeRegistrySavedData extends SavedData { entry.contains(PLAN_TAG, CompoundTag.TAG_COMPOUND) ? AgeGenerationPlan.load(entry.getCompound(PLAN_TAG)) : null, entry.contains(SEASON_MODE_TAG, CompoundTag.TAG_STRING) - ? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null + ? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null, + readRandomFeatures(entry) )); } return data; @@ -213,6 +217,16 @@ public final class AgeRegistrySavedData extends SavedData { traits.putFloat(CELESTIAL_PHASE_TAG, age.traits().celestialPhase()); entry.put(TRAITS_TAG, traits); if (age.plan() != null) entry.put(PLAN_TAG, age.plan().save()); + if (!age.randomFeatures().isEmpty()) { + ListTag featureTags = new ListTag(); + for (AgeFeatureSelection.Choice choice : age.randomFeatures()) { + CompoundTag feature = new CompoundTag(); + feature.putString("Id", choice.id().toString()); + choice.material().ifPresent(id -> feature.putString("Material", id.toString())); + featureTags.add(feature); + } + entry.put(RANDOM_FEATURES_TAG, featureTags); + } if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name()); serializedAges.add(entry); }); @@ -220,6 +234,18 @@ public final class AgeRegistrySavedData extends SavedData { return tag; } + private static List readRandomFeatures(CompoundTag age) { + ListTag tags = age.getList(RANDOM_FEATURES_TAG, CompoundTag.TAG_COMPOUND); + List features = new java.util.ArrayList<>(); + for (int index = 0; index < tags.size(); index++) { + CompoundTag item = tags.getCompound(index); + features.add(new AgeFeatureSelection.Choice(ResourceLocation.parse(item.getString("Id")), + item.contains("Material", CompoundTag.TAG_STRING) + ? Optional.of(ResourceLocation.parse(item.getString("Material"))) : Optional.empty())); + } + return List.copyOf(features); + } + private static AgeTraits readTraits(CompoundTag age) { if (!age.contains(TRAITS_TAG, CompoundTag.TAG_COMPOUND)) { return AgeTraits.LEGACY_DEFAULTS; diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeRuntimeProfiles.java b/src/main/java/net/dnorberg/rivencraft/book/AgeRuntimeProfiles.java index 88e6a67..ff19c17 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeRuntimeProfiles.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeRuntimeProfiles.java @@ -72,13 +72,17 @@ public final class AgeRuntimeProfiles { List features = parsed.symbols().stream() .filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE) .map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList(); - if (features.isEmpty() || PROFILES.isEmpty()) return selected; + boolean hasMaterial = parsed.symbols().stream().anyMatch(symbol -> + symbol.semantics().role() == SymbolSemantics.Role.BLOCK); + if ((features.isEmpty() && !hasMaterial) || PROFILES.isEmpty()) return selected; AgeRuntimeProfile initial = PROFILES.get(selected); if (initial != null && features.stream().allMatch(id -> - AgeFeatureSelection.error(id, initial, registries) == null)) return selected; + AgeFeatureSelection.error(id, initial, registries) == null) + && AgeFeatureSelection.supportsUnpairedMaterials(parsed, initial)) return selected; List compatible = PROFILES.entrySet().stream() .filter(entry -> features.stream().allMatch(id -> - AgeFeatureSelection.error(id, entry.getValue(), registries) == null)) + AgeFeatureSelection.error(id, entry.getValue(), registries) == null) + && AgeFeatureSelection.supportsUnpairedMaterials(parsed, entry.getValue())) .map(Map.Entry::getKey).sorted().toList(); return compatible.isEmpty() ? selected : compatible.get(Math.floorMod(Long.hashCode(seed), compatible.size())); diff --git a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java index de3d912..cc4a9b7 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java @@ -31,7 +31,8 @@ public final class FeatureWritingGameTests { var registries = helper.getLevel().registryAccess(); 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/surface_lakes", "feature/deep_lakes", "feature/ravines", + "feature/floating_islands"); helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(pages), "Supported feature pages must appear in the creative and JEI symbol feed"); for (String path : pages) { @@ -57,7 +58,8 @@ public final class FeatureWritingGameTests { helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), AgeFeatureSelection.resolved(plan), "Features must survive the saved-plan round trip"); for (AgeFeatureSelection.Choice choice : AgeFeatureSelection.resolved(plan)) { - if (AgeFeatureSelection.caves(choice.id()) || AgeFeatureSelection.ravines(choice.id())) continue; + if (AgeFeatureSelection.caves(choice.id()) || AgeFeatureSelection.ravines(choice.id()) + || AgeFeatureSelection.floatingIslands(choice.id())) continue; var placement = AgeFeatureSelection.placedFeature(choice.id()); helper.assertTrue(registries.lookupOrThrow(Registries.PLACED_FEATURE) .get(ResourceKey.create(Registries.PLACED_FEATURE, placement)).isPresent(), @@ -92,11 +94,78 @@ public final class FeatureWritingGameTests { AgeBiomeSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse( new OrderedWriting(List.of(panel(), page("terrain/standard"), page("feature/flowers")))), AgeTraits.select(87L), AgeRuntimeProfiles.resolve(AgeGenerationProfiles.STANDARD), - 87L, registries), registries).contains(choice("flowers")), + 87L, registries), registries, 87L).contains(choice("flowers")), "A valid surface feature must still compile"); helper.succeed(); } + @GameTest(template = "empty") + public static void floatingIslandsAreAdditiveAcrossSupportedTerrains(GameTestHelper helper) { + for (String terrain : List.of("standard", "amplified", "flat", "void")) { + OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/" + terrain), + page("feature/floating_islands"))); + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 450L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(450L), helper.getLevel().registryAccess()); + helper.assertValueEqual(plan.authoredTerrain(), id(terrain), + "Floating Islands must not replace the terrain page"); + List resolved = AgeFeatureSelection.resolved(plan); + helper.assertValueEqual(resolved.stream().map(AgeFeatureSelection.Choice::id).toList(), + List.of(id("floating_islands")), + "The island feature must be frozen independently of terrain"); + helper.assertTrue(resolved.getFirst().material().isPresent() + && AgeFeatureSelection.materialError(resolved.getFirst()) == null, + "An unmodified island feature must choose a valid full-block material"); + helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), + resolved, "The seeded island material must survive an Age reload"); + } + rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/cave"), + page("feature/floating_islands"))), "cannot form above Cave terrain"); + OrderedWriting materialWriting = new OrderedWriting(List.of(panel(), page("terrain/flat"), + block("glowstone"), page("feature/floating_islands"))); + AgeGenerationPlan materialPlan = AgeGenerationPlan.compile(materialWriting, 450L, + AgeGenerationProfiles.STANDARD, AgeTraits.select(450L), helper.getLevel().registryAccess()); + helper.assertValueEqual(AgeFeatureSelection.resolved(materialPlan), List.of( + new AgeFeatureSelection.Choice(id("floating_islands"), Optional.of( + ResourceLocation.withDefaultNamespace("glowstone")))), + "A preceding full-block page must set the island interior"); + AgeGenerationPlan randomPlan = AgeGenerationPlan.compile(new OrderedWriting(List.of(panel())), + 450L, AgeGenerationProfiles.STANDARD, AgeTraits.select(450L), + helper.getLevel().registryAccess()); + helper.assertValueEqual(AgeFeatureSelection.resolved(randomPlan), List.of(), + "A page-free plan must not implicitly author Floating Islands"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void voidBiomesAreIndependentOfFloatingIslands(GameTestHelper helper) { + OrderedWriting voidDefault = new OrderedWriting(List.of(panel(), page("terrain/void"))); + AgeGenerationPlan voidDefaultPlan = AgeGenerationPlan.compile(voidDefault, 450L, + AgeGenerationProfiles.STANDARD, AgeTraits.select(450L), helper.getLevel().registryAccess()); + helper.assertValueEqual(voidDefaultPlan.biomeSelection().biomes(), + List.of(net.minecraft.world.level.biome.Biomes.THE_VOID.location()), + "Void terrain without biome pages must keep The Void biome"); + OrderedWriting voidDesert = new OrderedWriting(List.of(panel(), page("terrain/void"), + page("biome/desert"), page("biome/single"))); + AgeGenerationPlan voidDesertPlan = AgeGenerationPlan.compile(voidDesert, 450L, + AgeGenerationProfiles.STANDARD, AgeTraits.select(450L), helper.getLevel().registryAccess()); + helper.assertValueEqual(voidDesertPlan.biomeSelection().biomes(), + List.of(net.minecraft.world.level.biome.Biomes.DESERT.location()), + "An authored biome must override The Void even with no generated blocks"); + helper.assertValueEqual(AgeGenerationPlan.load(voidDesertPlan.save()).biomeSelection(), + voidDesertPlan.biomeSelection(), "The Void Age biome must survive reload"); + OrderedWriting voidDesertWithoutController = new OrderedWriting(List.of(panel(), + page("terrain/void"), page("biome/desert"))); + AgeGenerationPlan implicitSinglePlan = AgeGenerationPlan.compile(voidDesertWithoutController, 450L, + AgeGenerationProfiles.STANDARD, AgeTraits.select(450L), helper.getLevel().registryAccess()); + helper.assertValueEqual(implicitSinglePlan.biomeSelection().biomes(), + List.of(net.minecraft.world.level.biome.Biomes.DESERT.location()), + "A Void Age with one authored biome must not add The Void as filler"); + rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/void"), + page("biome/desert"), page("biome/tiny"))), + "requires 2 distinct biomes"); + helper.succeed(); + } + @GameTest(template = "empty") public static void blockPagesModifyFeaturesAndSurviveReload(GameTestHelper helper) { var stone = ResourceLocation.withDefaultNamespace("stone"); @@ -146,7 +215,45 @@ public final class FeatureWritingGameTests { } @GameTest(template = "empty") - public static void incompatibleAndMisplacedBlockPagesAreRejected(GameTestHelper helper) { + public static void repeatedMaterialAndFeaturePagesRemainInWrittenPlan(GameTestHelper helper) { + OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/standard"), + block("stone"), block("stone"), page("feature/rocks"), page("feature/rocks"))); + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 87L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(87L), helper.getLevel().registryAccess()); + helper.assertValueEqual(plan.writing(), writing, + "Binding must keep every repeated material and feature page in order"); + helper.assertValueEqual(plan.symbols().size(), 5, + "The frozen plan must retain both material pages and both feature pages"); + List choices = AgeFeatureSelection.resolved(plan); + helper.assertValueEqual(choices.size(), 3, + "The first material gains an inferred feature; both authored features remain"); + helper.assertValueEqual(choices.get(1), new AgeFeatureSelection.Choice(id("rocks"), + Optional.of(ResourceLocation.withDefaultNamespace("stone"))), + "The second material must modify the first authored Rocks page"); + helper.assertValueEqual(choices.get(2).id(), id("rocks"), + "The repeated Rocks page must remain a second feature choice"); + helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), choices, + "Repeated pages and their feature choices must survive a plan reload"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void repeatedUnmodifiedRocksChooseDistinctMaterials(GameTestHelper helper) { + OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/standard"), + page("feature/rocks"), page("feature/rocks"), page("feature/rocks"), page("feature/rocks"))); + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 87L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(87L), helper.getLevel().registryAccess()); + List choices = AgeFeatureSelection.resolved(plan); + helper.assertValueEqual(choices.size(), 4, "All four Rocks pages must survive binding"); + helper.assertValueEqual(choices.stream().map(AgeFeatureSelection.Choice::material).distinct().count(), 4L, + "Each repeated unmodified Rocks page must receive a distinct material"); + helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), choices, + "The four materials must remain stable after reloading the Age plan"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void incompatibleAndUnpairedMaterialPages(GameTestHelper helper) { helper.assertTrue(AgeFeatureSelection.materialError(new AgeFeatureSelection.Choice( id("rocks"), Optional.of(ResourceLocation.withDefaultNamespace("torch")))) .contains("full solid block"), @@ -155,8 +262,26 @@ public final class FeatureWritingGameTests { block("stone"), page("feature/surface_lakes"))), "source-fluid block"); rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/standard"), block("stone"), page("feature/trees"))), "cannot consume a material page"); - rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/standard"), - block("stone"))), "immediately before a compatible feature"); + for (String material : List.of("stone", "water")) { + OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/standard"), + block(material))); + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 87L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(87L), helper.getLevel().registryAccess()); + List choices = AgeFeatureSelection.resolved(plan); + helper.assertValueEqual(choices.size(), 1, "An unpaired material page must gain one feature"); + helper.assertValueEqual(choices.getFirst().material(), + Optional.of(ResourceLocation.withDefaultNamespace(material)), + "The inferred feature must consume the authored material"); + helper.assertTrue(material.equals("water") + ? AgeFeatureSelection.isLake(choices.getFirst().id()) + : AgeFeatureSelection.isRock(choices.getFirst().id()) + || AgeFeatureSelection.floatingIslands(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"); + } + rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/void"), block("water"))), + "No compatible feature for material"); helper.succeed(); } diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientBookInteractionHandler.java b/src/main/java/net/dnorberg/rivencraft/client/ClientBookInteractionHandler.java index 54c2f39..1e58a8c 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/ClientBookInteractionHandler.java +++ b/src/main/java/net/dnorberg/rivencraft/client/ClientBookInteractionHandler.java @@ -93,9 +93,9 @@ public final class ClientBookInteractionHandler { } minecraft.setScreen(payload.fromStand() ? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits(), - payload.writingSymbols(), payload.diagnostics()) + payload.writingSymbols()) : new DescriptiveBookScreen(hand, payload.book(), payload.traits(), - payload.writingSymbols(), payload.diagnostics())); + payload.writingSymbols())); }); } } diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java index c3f6c11..80cec50 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java @@ -16,7 +16,6 @@ import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata; import net.dnorberg.rivencraft.writing.WritingEntry; -import net.dnorberg.rivencraft.writing.WritingGrammar; import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer; import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers; import net.minecraft.core.BlockPos; @@ -45,7 +44,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo private static final int LINK_PANEL_Y = 20; private static final int LINK_PANEL_WIDTH = 132; private static final int LINK_PANEL_HEIGHT = 83; - private static final int DIAGNOSTICS_PER_PAGE = 2; private static final Component EXPLORE_ICON = Component.literal("👁"); private final AgeReference age; @@ -53,7 +51,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo private final AgeTraits traits; private final OrderedWriting writing; private final List writingSymbols; - private final List diagnostics; private final boolean pageFree; private final String author; private final InteractionHand hand; @@ -67,15 +64,13 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo private boolean movingToExploreOverlay; public DescriptiveBookScreen(InteractionHand hand, ItemStack book, @Nullable AgeTraits traits, - List writingSymbols, - List diagnostics) { + List writingSymbols) { super(book.getHoverName()); age = book.get(ModDataComponents.AGE_REFERENCE.get()); generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get()); this.traits = traits; writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY); this.writingSymbols = List.copyOf(writingSymbols); - this.diagnostics = List.copyOf(diagnostics); pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL); author = book.get(ModDataComponents.BOOK_AUTHOR.get()); this.hand = hand; @@ -83,15 +78,13 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo } public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book, @Nullable AgeTraits traits, - List writingSymbols, - List diagnostics) { + List writingSymbols) { super(book.getHoverName()); age = book.get(ModDataComponents.AGE_REFERENCE.get()); generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get()); this.traits = traits; writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY); this.writingSymbols = List.copyOf(writingSymbols); - this.diagnostics = List.copyOf(diagnostics); pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL); author = book.get(ModDataComponents.BOOK_AUTHOR.get()); hand = InteractionHand.MAIN_HAND; @@ -173,13 +166,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo else if (pageIndex < pageCount() && mouseX >= left + 293 && mouseX < left + 320) graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.next_page"), mouseX, mouseY); } - if (pageIndex > 0 && pageIndex < writing.entries().size() - && mouseX >= left + 285 && mouseX < left + 306 - && mouseY >= top + 149 && mouseY < top + 169) { - diagnostics.stream().filter(diagnostic -> diagnostic.index() == pageIndex) - .findFirst().ifPresent(diagnostic -> graphics.renderTooltip(font, - Component.literal(diagnostic.message()), mouseX, mouseY)); - } if (pageIndex == 0 && age != null && !pageFree && traits != null && mouseX >= left + 38 && mouseX < left + 154 && mouseY >= top + 38 && mouseY < top + 67) { @@ -222,9 +208,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo } else if (pageIndex < writing.entries().size()) { LegacyBookArtwork.drawFullPage(graphics, left, top); renderWrittenPage(graphics, left, top); - } else if (!diagnostics.isEmpty()) { - LegacyBookArtwork.drawFullPage(graphics, left, top); - renderDiagnosticsPage(graphics, left, top); } String counter = pageIndex + "/" + pageCount(); graphics.drawString(font, counter, left + 165 - font.width(counter) / 2, @@ -233,9 +216,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo private int pageCount() { // Older page-free Descriptive Books do not have a BOOK_WRITING component. - int authoredPages = Math.max(1, writing.entries().size() - 1); - return authoredPages + (diagnostics.isEmpty() ? 0 - : (diagnostics.size() + DIAGNOSTICS_PER_PAGE - 1) / DIAGNOSTICS_PER_PAGE); + return Math.max(1, writing.entries().size() - 1); } private void renderFirstPageText(GuiGraphics graphics, int left, int top) { @@ -298,28 +279,6 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo x, top + 151, 126, UNRESOLVED_TEXT_COLOR); } graphics.disableScissor(); - if (diagnostics.stream().anyMatch(diagnostic -> diagnostic.index() == pageIndex)) { - graphics.drawString(font, "!", left + 293, top + 153, UNRESOLVED_TEXT_COLOR, false); - } - } - - private void renderDiagnosticsPage(GuiGraphics graphics, int left, int top) { - drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.writing_notes"), - left + 176, top + 35, 126); - int start = (pageIndex - Math.max(1, writing.entries().size() - 1) - 1) * DIAGNOSTICS_PER_PAGE; - int y = top + 52; - graphics.enableScissor(left + 174, top + 50, left + 306, top + 173); - for (int index = start; index < Math.min(start + DIAGNOSTICS_PER_PAGE, diagnostics.size()); index++) { - WritingGrammar.Diagnostic diagnostic = diagnostics.get(index); - int color = diagnostic.error() ? UNRESOLVED_TEXT_COLOR : BOOK_TEXT_COLOR; - Component label = diagnostic.index() < 0 - ? Component.translatable("screen.rivencraft.age.writing_general") - : Component.translatable("screen.rivencraft.age.writing_page", diagnostic.index() + 1); - y = drawWrappedWithoutShadow(graphics, label, left + 176, y, 126, color) + 3; - y = drawWrappedWithoutShadow(graphics, Component.literal(diagnostic.message()), - left + 176, y, 126, color) + 10; - } - graphics.disableScissor(); } private void turnPage(int direction) { diff --git a/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java b/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java index 0b37dcb..09c8cbd 100644 --- a/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java +++ b/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java @@ -68,7 +68,7 @@ public final class RivencraftJeiPlugin implements IModPlugin { @Override public void registerExtraIngredients(IExtraIngredientRegistration registration) { List pages = new ArrayList<>(); - pages.add(PageItem.createLinkPanelPage()); + pages.addAll(PageItem.creativeLinkPanelPages()); var connection = Minecraft.getInstance().getConnection(); if (connection != null) { pages.addAll(PageItem.creativeSymbolPages(connection.registryAccess())); diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java index f9a2174..0a9dead 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java @@ -5,8 +5,10 @@ import com.mojang.serialization.JsonOps; import io.netty.buffer.Unpooled; import java.util.Arrays; import java.util.List; +import java.util.Set; import java.util.UUID; import net.dnorberg.rivencraft.book.AgeReference; +import net.dnorberg.rivencraft.book.LinkProperties; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.SymbolCatalog; @@ -42,6 +44,20 @@ public final class WritingDataGameTests { private WritingDataGameTests() { } + @GameTest(template = "empty") + public static void creativeLinkPanelsIncludeEveryActiveModifierCombination(GameTestHelper helper) { + List pages = PageItem.creativeLinkPanelPages(); + helper.assertValueEqual(pages.size(), 8, "The base panel and seven modifier combinations must be listed"); + helper.assertTrue(pages.stream().allMatch(page -> PageItem.content(page).kind() + == PageContent.Kind.LINK_PANEL), "All variants must be usable Link Panels"); + Set flags = pages.stream().map(page -> page.getOrDefault( + ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE).flags()) + .collect(java.util.stream.Collectors.toSet()); + helper.assertValueEqual(flags, Set.of(0, 1, 4, 8, 5, 9, 12, 13), + "Every active modifier combination must be represented exactly once"); + helper.succeed(); + } + @GameTest(template = "empty") public static void orderedWritingPreservesKnownUnknownAndLinkPanelEntries(GameTestHelper helper) { SymbolReference known = reference("rivencraft:terrain/standard"); diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java index ffe6fd9..61cf609 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/WritingGrammarGameTests.java @@ -99,8 +99,11 @@ public final class WritingGrammarGameTests { WritingEntry.symbol(nativeBiomes)), WritingGrammar.Code.CONFLICT); assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains), WritingEntry.symbol(forest), WritingEntry.symbol(single)), WritingGrammar.Code.CONFLICT); - assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(caves), - WritingEntry.symbol(caves)), WritingGrammar.Code.DUPLICATE); + WritingGrammar.Result repeatedFeatures = parse(resolver, WritingEntry.linkPanel(), + WritingEntry.symbol(caves), WritingEntry.symbol(caves)); + helper.assertTrue(repeatedFeatures.canCreate(), "Repeated feature pages must remain authorable"); + helper.assertValueEqual(repeatedFeatures.symbols().size(), 2, + "Repeated feature pages must remain separate ordered symbols"); assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(ref("test:missing"))), WritingGrammar.Code.UNRESOLVED); assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(displayOnly)), diff --git a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index f6f5b76..965376b 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -210,6 +210,27 @@ public final class PageItem extends Item { return create(PageContent.LINK_PANEL); } + /** Every Link Panel modifier combination the Ink Mixer can produce. */ + public static List creativeLinkPanelPages() { + int[] flags = { + 0, + LinkProperties.DISARM, + LinkProperties.MAINTAIN_MOMENTUM, + LinkProperties.INTRA_LINKING, + LinkProperties.DISARM | LinkProperties.MAINTAIN_MOMENTUM, + LinkProperties.DISARM | LinkProperties.INTRA_LINKING, + LinkProperties.MAINTAIN_MOMENTUM | LinkProperties.INTRA_LINKING, + LinkProperties.DISARM | LinkProperties.MAINTAIN_MOMENTUM | LinkProperties.INTRA_LINKING + }; + List pages = new ArrayList<>(flags.length); + for (int flag : flags) { + ItemStack page = createLinkPanelPage(); + if (flag != 0) page.set(ModDataComponents.LINK_PROPERTIES.get(), new LinkProperties(flag)); + pages.add(page); + } + return pages; + } + /** Restores saved unknown references without granting them validity for new writing operations. */ public static ItemStack createRecoveredPage(SymbolReference reference) { return create(PageContent.unresolved(reference)); diff --git a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java index 4ed0fba..3d381ba 100644 --- a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java +++ b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java @@ -59,7 +59,7 @@ public final class ModNetworking { } public static void register(RegisterPayloadHandlersEvent event) { - PayloadRegistrar registrar = event.registrar("14"); + PayloadRegistrar registrar = event.registrar("15"); registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC, ModNetworking::requestDescriptiveBookScreen); registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC, @@ -120,15 +120,12 @@ public final class ModNetworking { .filter(entry -> entry.kind() == WritingEntry.Kind.SYMBOL) .map(entry -> SymbolCatalog.display(entry.symbol().orElseThrow())) .toList(); - // Diagnostics are advisory display data from the authoritative server catalog. - // Opening a book never recompiles or changes its persisted Age plan. - var diagnostics = WritingGrammar.parse(writing).diagnostics(); PacketDistributor.sendToPlayer(player, new OpenDescriptiveBookScreenPayload( payload.fromStand(), payload.offhand(), GlobalPos.of(player.level().dimension(), payload.standPos()), access.stack().copy(), definition == null ? null : definition.plan() != null && definition.plan().biomeSelection() != null ? definition.traits().withBiomeDistribution(definition.plan().biomeSelection().distribution()) - : definition.traits(), writingSymbols, diagnostics)); + : definition.traits(), writingSymbols)); } private static void bindDescriptiveBook(BindDescriptiveBookPayload payload, IPayloadContext context) { diff --git a/src/main/java/net/dnorberg/rivencraft/network/OpenDescriptiveBookScreenPayload.java b/src/main/java/net/dnorberg/rivencraft/network/OpenDescriptiveBookScreenPayload.java index 25a5d2e..ff0bf71 100644 --- a/src/main/java/net/dnorberg/rivencraft/network/OpenDescriptiveBookScreenPayload.java +++ b/src/main/java/net/dnorberg/rivencraft/network/OpenDescriptiveBookScreenPayload.java @@ -6,7 +6,6 @@ import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.book.AgeTraits; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata; -import net.dnorberg.rivencraft.writing.WritingGrammar; import net.minecraft.core.GlobalPos; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; @@ -22,8 +21,7 @@ public record OpenDescriptiveBookScreenPayload( GlobalPos source, ItemStack book, @Nullable AgeTraits traits, - List writingSymbols, - List diagnostics + List writingSymbols ) implements CustomPacketPayload { public static final Type TYPE = new Type<>( @@ -52,15 +50,6 @@ public record OpenDescriptiveBookScreenPayload( } buffer.writeVarInt(payload.writingSymbols().size()); payload.writingSymbols().forEach(symbol -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, symbol)); - if (payload.diagnostics().size() > OrderedWriting.MAX_ENTRIES * 2) { - throw new IllegalArgumentException("Too many book grammar diagnostics"); - } - buffer.writeVarInt(payload.diagnostics().size()); - payload.diagnostics().forEach(diagnostic -> { - buffer.writeEnum(diagnostic.code()); - buffer.writeVarInt(diagnostic.index()); - buffer.writeUtf(diagnostic.message(), 1024); - }); } private static OpenDescriptiveBookScreenPayload decode(RegistryFriendlyByteBuf buffer) { @@ -86,17 +75,8 @@ public record OpenDescriptiveBookScreenPayload( for (int index = 0; index < count; index++) { writingSymbols.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer)); } - int diagnosticCount = buffer.readVarInt(); - if (diagnosticCount < 0 || diagnosticCount > OrderedWriting.MAX_ENTRIES * 2) { - throw new IllegalArgumentException("Invalid book grammar diagnostic count " + diagnosticCount); - } - List diagnostics = new ArrayList<>(diagnosticCount); - for (int index = 0; index < diagnosticCount; index++) { - diagnostics.add(new WritingGrammar.Diagnostic( - buffer.readEnum(WritingGrammar.Code.class), buffer.readVarInt(), buffer.readUtf(1024))); - } return new OpenDescriptiveBookScreenPayload(fromStand, offhand, source, book, traits, - List.copyOf(writingSymbols), List.copyOf(diagnostics)); + List.copyOf(writingSymbols)); } @Override diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index 891819b..fdc54a3 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -38,7 +38,8 @@ public final class ModCreativeTabs { CREATIVE_TABS.register("rivencraft_link_pages", () -> CreativeModeTab.builder() .title(Component.translatable("itemGroup.rivencraft.pages.link")) .icon(PageItem::createLinkPanelPage) - .displayItems((parameters, output) -> output.accept(PageItem.createLinkPanelPage())) + .displayItems((parameters, output) -> + PageItem.creativeLinkPanelPages().forEach(output::accept)) .build()); private static final List CATEGORY_ORDER = List.of( diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java index 2bda4e0..faff787 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java @@ -3,7 +3,10 @@ package net.dnorberg.rivencraft.world; import com.mojang.serialization.Codec; import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; import net.dnorberg.rivencraft.book.AgeFeatureSelection; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.core.BlockPos; @@ -29,6 +32,7 @@ import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; import net.minecraft.world.level.levelgen.RandomState; import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.blending.Blender; import net.minecraft.world.level.levelgen.carver.CarvingContext; import net.minecraft.world.level.levelgen.carver.ConfiguredWorldCarver; import net.minecraft.world.level.levelgen.placement.BiomeFilter; @@ -67,6 +71,22 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { @Override protected MapCodec codec() { return CODEC; } + @Override + public CompletableFuture fillFromNoise(Blender blender, RandomState random, + StructureManager structures, ChunkAccess chunk) { + CompletableFuture filled = super.fillFromNoise(blender, random, structures, chunk); + if (features.stream().noneMatch(choice -> AgeFeatureSelection.floatingIslands(choice.id()))) return filled; + return filled.thenApply(result -> { + int occurrence = 0; + for (AgeFeatureSelection.Choice choice : features) { + if (!AgeFeatureSelection.floatingIslands(choice.id())) continue; + FloatingIslandFeature.generate(result, getBiomeSource(), random, + ageSeed ^ (long) occurrence++ * 0x9E3779B97F4A7C15L, choice); + } + return result; + }); + } + @Override public void applyCarvers(WorldGenRegion level, long seed, RandomState random, BiomeManager biomes, StructureManager structures, ChunkAccess chunk, GenerationStep.Carving step) { @@ -116,8 +136,10 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { int x = chunk.getPos().getMinBlockX(); int z = chunk.getPos().getMinBlockZ(); BlockPos origin = new BlockPos(x, level.getMinBuildHeight(), z); + Map occurrences = new HashMap<>(); for (int index = 0; index < features.size(); index++) { AgeFeatureSelection.Choice choice = features.get(index); + int occurrence = occurrences.merge(choice, 1, Integer::sum) - 1; if (AgeFeatureSelection.surfaceVegetation(choice.id())) { int surface = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 8); if (!AgeFeatureSelection.supportsSurfaceVegetation( @@ -148,7 +170,8 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { ? RarityFilter.onAverageOnceEvery(12) : modifier) .toList()); long seed = ageSeed ^ (long) x * 341873128712L ^ (long) z * 132897987541L - ^ (long) choice.hashCode() * 0x9E3779B97F4A7C15L; + ^ (long) choice.hashCode() * 0x9E3779B97F4A7C15L + ^ (long) occurrence * 0xD1B54A32D192ED03L; authored.place(level, this, RandomSource.create(seed), origin); } } diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java index f35ca1a..fc4b820 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java @@ -1,10 +1,12 @@ package net.dnorberg.rivencraft.world; +import com.mojang.serialization.Codec; import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Stream; +import net.dnorberg.rivencraft.book.AgeFeatureSelection; import net.minecraft.Util; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; @@ -32,17 +34,30 @@ import net.minecraft.world.level.levelgen.structure.StructureSet; public final class AgeFlatChunkGenerator extends ChunkGenerator { public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(BiomeSource.CODEC.fieldOf("biome_source").forGetter(generator -> generator.biomeSource), - FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(AgeFlatChunkGenerator::settings)) + FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(AgeFlatChunkGenerator::settings), + AgeFeatureSelection.Choice.CODEC.listOf().optionalFieldOf("age_features", List.of()) + .forGetter(generator -> generator.features), + Codec.LONG.optionalFieldOf("age_seed", 0L).forGetter(generator -> generator.ageSeed)) .apply(instance, AgeFlatChunkGenerator::new)); private final FlatLevelGeneratorSettings settings; + private final List features; + private final long ageSeed; public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) { + this(biomeSource, settings, List.of(), 0L); + } + + public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings, + List features, long ageSeed) { super(biomeSource, Util.memoize(settings::adjustGenerationSettings)); this.settings = settings; + this.features = List.copyOf(features); + this.ageSeed = ageSeed; } public FlatLevelGeneratorSettings settings() { return settings; } + public List ageFeatures() { return features; } @Override protected MapCodec codec() { return CODEC; } @@ -82,6 +97,12 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator { } } } + int islandOccurrence = 0; + for (AgeFeatureSelection.Choice choice : features) { + if (!AgeFeatureSelection.floatingIslands(choice.id())) continue; + FloatingIslandFeature.generate(chunk, biomeSource, random, + ageSeed ^ (long) islandOccurrence++ * 0x9E3779B97F4A7C15L, choice); + } return CompletableFuture.completedFuture(chunk); } diff --git a/src/main/java/net/dnorberg/rivencraft/world/FloatingIslandFeature.java b/src/main/java/net/dnorberg/rivencraft/world/FloatingIslandFeature.java new file mode 100644 index 0000000..03216d3 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/FloatingIslandFeature.java @@ -0,0 +1,100 @@ +package net.dnorberg.rivencraft.world; + +import net.dnorberg.rivencraft.book.AgeBiomeSelection; +import net.dnorberg.rivencraft.book.AgeFeatureSelection; +import net.minecraft.core.BlockPos; +import net.minecraft.core.QuartPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.tags.BiomeTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.biome.BiomeSource; +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; +import net.minecraft.world.level.levelgen.RandomState; + +/** Adds rare, high island clusters to an existing noise terrain before its surface is built. */ +final class FloatingIslandFeature { + private static final long SALT = 0x464C4F4154494E47L; + private static final int SOURCE_RANGE = 3; + private static final int CHANCE = 128; + + private FloatingIslandFeature() {} + + static void generate(ChunkAccess chunk, BiomeSource biomes, RandomState noise, long ageSeed, + AgeFeatureSelection.Choice choice) { + ChunkPos target = chunk.getPos(); + BlockState material = choice.material().map(id -> BuiltInRegistries.BLOCK.get(id).defaultBlockState()) + .orElse(null); + for (int sourceX = target.x - SOURCE_RANGE; sourceX <= target.x + SOURCE_RANGE; sourceX++) { + for (int sourceZ = target.z - SOURCE_RANGE; sourceZ <= target.z + SOURCE_RANGE; sourceZ++) { + RandomSource random = random(ageSeed, sourceX, sourceZ); + if (random.nextInt(CHANCE) != 0) continue; + int centerX = sourceX * 16 + random.nextInt(16); + int centerZ = sourceZ * 16 + random.nextInt(16); + int centerY = Math.min(chunk.getMaxBuildHeight() - 30, 168 + random.nextInt(48)); + if (centerY < chunk.getMinBuildHeight() + 24) continue; + int radiusX = 24 + random.nextInt(18); + int radiusZ = 24 + random.nextInt(18); + fill(chunk, biomes, noise, ageSeed, material, centerX, centerY, centerZ, radiusX, radiusZ); + } + } + } + + private static void fill(ChunkAccess chunk, BiomeSource biomes, RandomState noise, long ageSeed, + BlockState material, + int centerX, int centerY, int centerZ, int radiusX, int radiusZ) { + int originX = chunk.getPos().getMinBlockX(); + int originZ = chunk.getPos().getMinBlockZ(); + 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++) { + int worldX = originX + localX; + double dx = (worldX - centerX) / (double) radiusX; + for (int localZ = 0; localZ < 16; localZ++) { + int worldZ = originZ + localZ; + double dz = (worldZ - centerZ) / (double) radiusZ; + double distance = dx * dx + dz * dz; + if (distance >= 1.0) continue; + + // Coordinate noise keeps adjacent chunks seamless without sharing mutable RNG state. + long detail = mix(ageSeed ^ (long) centerX * 341873128712L + ^ (long) centerZ * 132897987541L ^ (long) worldX * 42317861L + ^ (long) worldZ * 374761393L); + int top = centerY + (int) Math.round(3 * (1 - distance)) + (int) Math.floorMod(detail, 3) - 1; + int depth = Math.max(1, (int) Math.round(3 + 17 * Math.pow(1 - distance, 1.5))); + var biome = biomes.getNoiseBiome(QuartPos.fromBlock(worldX), QuartPos.fromBlock(top), + QuartPos.fromBlock(worldZ), noise.sampler()); + BlockState base = material != null ? material + : biome.is(BiomeTags.IS_NETHER) ? Blocks.NETHERRACK.defaultBlockState() + : biome.is(BiomeTags.IS_END) ? Blocks.END_STONE.defaultBlockState() + : Blocks.STONE.defaultBlockState(); + BlockState soil = biome.is(BiomeTags.IS_NETHER) || biome.is(BiomeTags.IS_END) + || biome.is(AgeBiomeSelection.CAVE_BIOMES) ? base : Blocks.DIRT.defaultBlockState(); + BlockState cap = soil == base ? base : Blocks.GRASS_BLOCK.defaultBlockState(); + for (int y = Math.max(top - depth, chunk.getMinBuildHeight()); y <= top; y++) { + pos.set(worldX, y, worldZ); + if (!chunk.getBlockState(pos).isAir()) continue; + BlockState state = y == top ? cap : y >= top - 3 ? soil : base; + chunk.setBlockState(pos, state, false); + surface.update(localX, y, localZ, state); + floor.update(localX, y, localZ, state); + } + } + } + } + + private static RandomSource random(long ageSeed, int x, int z) { + return RandomSource.create(mix(ageSeed ^ SALT ^ (long) x * 341873128712L + ^ (long) z * 132897987541L)); + } + + 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/writing/WritingGrammar.java b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java index 242dfef..7c863f3 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java @@ -2,7 +2,6 @@ package net.dnorberg.rivencraft.writing; import java.util.ArrayList; import java.util.EnumMap; -import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -37,7 +36,6 @@ public final class WritingGrammar { if (writing.entries().isEmpty()) return new Result(symbols, diagnostics); EnumMap singular = new EnumMap<>(Role.class); - Set features = new HashSet<>(); int panels = 0; int lastStage = -1; int biomeCount = 0; @@ -71,15 +69,15 @@ public final class WritingGrammar { ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow()); symbols.add(parsed); if (pendingBlock != null && role != Role.FEATURE) { - diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingBlock.index(), - "Place this material page immediately before a compatible feature")); + // A material page without a following feature receives a seeded compatible + // feature when the Age plan is compiled. pendingBlock = null; } if (role == Role.BLOCK) { pendingBlock = parsed; } 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") + if (!Set.of("rocks", "surface_lakes", "deep_lakes", "lava_lakes", "floating_islands") .contains(feature.getPath()) || !feature.getNamespace().equals("rivencraft")) { diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingBlock.index(), "This feature cannot consume a material page")); @@ -102,10 +100,6 @@ public final class WritingGrammar { + " already supplies that category")); continue; } - } else if (role == Role.FEATURE && !features.add(reference.id())) { - diagnostics.add(new Diagnostic(Code.DUPLICATE, index, - "Remove the repeated feature page " + reference.id())); - continue; } if (role == Role.BIOME) biomeCount++; int stage = stage(role); @@ -116,8 +110,6 @@ public final class WritingGrammar { lastStage = stage; } } - if (pendingBlock != null) diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingBlock.index(), - "Place this material page immediately before a compatible feature")); if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1, "Add a Link Panel as the first page")); if (symbols.isEmpty()) return new Result(symbols, diagnostics); diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 0e360f8..bff8053 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -95,9 +95,6 @@ "screen.rivencraft.age.pages": "Pages", "screen.rivencraft.age.details": "Details", "screen.rivencraft.age.pages_count": "Writing %s/%s", - "screen.rivencraft.age.writing_notes": "Writing notes", - "screen.rivencraft.age.writing_general": "Book", - "screen.rivencraft.age.writing_page": "Page %s", "screen.rivencraft.age.trait.biomes": "Biomes: %s", "screen.rivencraft.age.trait.lighting": "Lighting: %s", "screen.rivencraft.age.trait.weather": "Weather: %s", @@ -231,7 +228,7 @@ "symbol.rivencraft.phase.setting.description": "Set the celestial phase to setting.", "symbol.rivencraft.feature.caves": "Caves", "symbol.rivencraft.feature.caves.description": "Add extra caves to the chosen noise terrain.", - "symbol.rivencraft.block.generated.description": "Material modifier; place immediately before a compatible feature page.", + "symbol.rivencraft.block.generated.description": "Place before a compatible feature page, or let the Age choose a compatible feature.", "symbol.rivencraft.feature.flowers": "Flowers", "symbol.rivencraft.feature.flowers.description": "Add vanilla flower patches to surface terrain.", "symbol.rivencraft.feature.trees": "Trees", @@ -246,6 +243,8 @@ "symbol.rivencraft.feature.deep_lakes.description": "Add underground lakes using a random fluid, or specify one with a fluid page.", "symbol.rivencraft.feature.ravines": "Ravines", "symbol.rivencraft.feature.ravines.description": "Add vanilla canyon carving to noise terrain.", + "symbol.rivencraft.feature.floating_islands": "Floating Islands", + "symbol.rivencraft.feature.floating_islands.description": "Add high floating islands above the chosen surface terrain.", "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/floating_islands.json b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/floating_islands.json new file mode 100644 index 0000000..950ed06 --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/floating_islands.json @@ -0,0 +1,8 @@ +{ + "schema_version": 2, + "name": "symbol.rivencraft.feature.floating_islands", + "description": "symbol.rivencraft.feature.floating_islands.description", + "category": "rivencraft:feature", + "semantics": { "role": "feature", "value": "rivencraft:floating_islands" }, + "poem": "Terrain Elevation Island Sky" +}