Public Access
This commit is contained in:
1 parent
84a1f32732
commit
6c3b4f1885
29 files changed
+919
-25
No files matched your search
@@ -24,6 +24,7 @@ import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.dnorberg.rivencraft.book.AgeBiomeSelection;
|
||||
import net.dnorberg.rivencraft.book.AgeFeatureSelection;
|
||||
import net.minecraft.tags.BiomeTags;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
@@ -151,7 +152,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
var selectedProfile = terrain.isPresent()
|
||||
? ((SymbolSemantics.ResourceValue) terrain.orElseThrow().semantics().value()).id()
|
||||
: level instanceof ServerLevel serverLevel
|
||||
? AgeRegistrySavedData.previewGenerationProfile(serverLevel, title)
|
||||
? AgeRegistrySavedData.previewGenerationProfile(serverLevel, title, currentWriting())
|
||||
: AgeGenerationProfiles.STANDARD;
|
||||
var runtimeProfile = AgeRuntimeProfiles.profiles().get(selectedProfile);
|
||||
boolean cave = runtimeProfile != null && AgeBiomeSelection.isCave(runtimeProfile);
|
||||
@@ -168,6 +169,33 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
symbol.index(), "Cave Ages require cave or Nether biomes; remove " + id));
|
||||
}
|
||||
});
|
||||
if (runtimeProfile != null) {
|
||||
parsed.symbols().stream().filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
|
||||
.forEach(symbol -> {
|
||||
var id = ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id();
|
||||
String error = AgeFeatureSelection.error(id, runtimeProfile, level.registryAccess());
|
||||
if (error != null) diagnostics.add(new WritingGrammar.Diagnostic(
|
||||
WritingGrammar.Code.UNSUPPORTED_FEATURE, symbol.index(), error));
|
||||
});
|
||||
}
|
||||
// Compile against the actual title-derived Age seed as a final compatibility
|
||||
// check. This also pinpoints biome-pool conflicts that cannot be inferred from
|
||||
// an individual feature page alone (for example Trees with only Nether biomes).
|
||||
if (level instanceof ServerLevel serverLevel && diagnostics.stream().noneMatch(
|
||||
diagnostic -> diagnostic.code() == WritingGrammar.Code.UNSUPPORTED_FEATURE)) {
|
||||
try {
|
||||
AgeRegistrySavedData.validateNewWriting(serverLevel, title, currentWriting());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
if (exception.getMessage() != null && (exception.getMessage().startsWith("Feature ")
|
||||
|| exception.getMessage().startsWith("Caves "))) {
|
||||
parsed.symbols().stream().filter(symbol -> symbol.semantics().role()
|
||||
== SymbolSemantics.Role.FEATURE).findFirst().ifPresent(symbol ->
|
||||
diagnostics.add(new WritingGrammar.Diagnostic(
|
||||
WritingGrammar.Code.UNSUPPORTED_FEATURE, symbol.index(),
|
||||
exception.getMessage())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new WritingGrammar.Result(parsed.symbols(), diagnostics);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import net.minecraft.world.level.biome.FixedBiomeSource;
|
||||
import net.minecraft.world.level.biome.MultiNoiseBiomeSource;
|
||||
import net.dnorberg.rivencraft.world.ScaledBiomeSource;
|
||||
import net.dnorberg.rivencraft.world.AgeFlatChunkGenerator;
|
||||
import net.dnorberg.rivencraft.world.AgeFeatureChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
|
||||
import net.minecraft.world.level.dimension.DimensionType;
|
||||
@@ -321,7 +322,10 @@ public final class AgeDimensionService {
|
||||
ResourceKey<NoiseGeneratorSettings> settingsKey = ResourceKey.create(
|
||||
Registries.NOISE_SETTINGS, profile.noiseSettings());
|
||||
NoiseGeneratorSettings baseSettings = noiseSettings.getOrThrow(settingsKey).value();
|
||||
return new NoiseBasedChunkGenerator(biomeSource, Holder.direct(withBiomeMaterialRules(baseSettings)));
|
||||
Holder<NoiseGeneratorSettings> settings = Holder.direct(withBiomeMaterialRules(baseSettings));
|
||||
List<AgeFeatureSelection.Choice> features = AgeFeatureSelection.resolved(age.plan());
|
||||
return features.isEmpty() ? new NoiseBasedChunkGenerator(biomeSource, settings)
|
||||
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed());
|
||||
}
|
||||
|
||||
private static NoiseGeneratorSettings withBiomeMaterialRules(NoiseGeneratorSettings base) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.tags.BiomeTags;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
/** Validates and freezes supported feature pages and their preceding material modifiers. */
|
||||
public final class AgeFeatureSelection {
|
||||
private AgeFeatureSelection() {}
|
||||
|
||||
public record Choice(ResourceLocation id, Optional<ResourceLocation> material) {
|
||||
public static final Codec<Choice> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
ResourceLocation.CODEC.fieldOf("id").forGetter(Choice::id),
|
||||
ResourceLocation.CODEC.optionalFieldOf("material").forGetter(Choice::material)
|
||||
).apply(instance, Choice::new));
|
||||
|
||||
public Choice {
|
||||
if (id == null || material == null) throw new IllegalArgumentException("Missing feature choice");
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Choice> compile(WritingGrammar.Result writing, AgeRuntimeProfile profile,
|
||||
AgeBiomeSelection biomes, RegistryAccess registries) {
|
||||
List<Choice> choices = choices(writing.symbols().stream()
|
||||
.map(symbol -> symbol.semantics()).toList());
|
||||
for (Choice choice : choices) {
|
||||
String error = error(choice.id(), profile, registries);
|
||||
if (error == null) error = materialError(choice);
|
||||
if (error != null) throw new IllegalArgumentException(error);
|
||||
if (surfaceVegetation(choice.id()) && biomes.biomes().stream()
|
||||
.allMatch(id -> !supportsSurfaceVegetation(id, registries))) {
|
||||
throw new IllegalArgumentException("Feature " + choice.id()
|
||||
+ " needs at least one surface Overworld biome in the selected pool");
|
||||
}
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
private static List<Choice> choices(List<SymbolSemantics> symbols) {
|
||||
List<Choice> choices = new ArrayList<>();
|
||||
ResourceLocation pendingMaterial = null;
|
||||
for (SymbolSemantics semantics : symbols) {
|
||||
if (semantics.role() == SymbolSemantics.Role.BLOCK) {
|
||||
pendingMaterial = ((SymbolSemantics.ResourceValue) semantics.value()).id();
|
||||
} else if (semantics.role() == SymbolSemantics.Role.FEATURE) {
|
||||
choices.add(new Choice(((SymbolSemantics.ResourceValue) semantics.value()).id(),
|
||||
Optional.ofNullable(pendingMaterial)));
|
||||
pendingMaterial = null;
|
||||
}
|
||||
}
|
||||
return List.copyOf(choices);
|
||||
}
|
||||
|
||||
public static List<Choice> 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();
|
||||
}
|
||||
|
||||
private static ResourceLocation randomMaterial(long ageSeed, ResourceLocation feature) {
|
||||
var category = isRock(feature) ? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY;
|
||||
List<ResourceLocation> pool = 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()));
|
||||
}
|
||||
|
||||
public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) {
|
||||
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";
|
||||
}
|
||||
var carvers = registries.lookupOrThrow(Registries.CONFIGURED_CARVER);
|
||||
List<String> ids = caves(choice) ? List.of("cave", "nether_cave") : List.of("canyon");
|
||||
for (String path : ids) {
|
||||
var key = ResourceKey.create(Registries.CONFIGURED_CARVER, ResourceLocation.withDefaultNamespace(path));
|
||||
if (carvers.get(key).isEmpty()) return "Feature " + choice
|
||||
+ " requires missing configured carver " + key.location();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
ResourceLocation placed = placedFeature(choice);
|
||||
if (placed == null) return "Unsupported feature " + choice
|
||||
+ "; remove its page (custom structures remain future work)";
|
||||
if (profile.terrain() != AgeRuntimeProfile.Terrain.NOISE || AgeBiomeSelection.isCave(profile)) {
|
||||
return "Feature " + choice + " requires surface noise terrain; not Flat, Void, or Cave";
|
||||
}
|
||||
if (registries.lookupOrThrow(Registries.PLACED_FEATURE)
|
||||
.get(ResourceKey.create(Registries.PLACED_FEATURE, placed)).isEmpty()) {
|
||||
return "Feature " + choice + " requires missing placed feature " + placed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String materialError(Choice choice) {
|
||||
if (choice.material().isEmpty()) return null;
|
||||
ResourceLocation material = choice.material().orElseThrow();
|
||||
var block = BuiltInRegistries.BLOCK.getOptional(material);
|
||||
if (block.isEmpty() || block.orElseThrow() == Blocks.AIR) {
|
||||
return "Feature " + choice.id() + " references missing block material " + material;
|
||||
}
|
||||
if (isRock(choice.id())) {
|
||||
if (!BlockSymbolPages.isRockMaterial(block.orElseThrow())) {
|
||||
return "Feature " + choice.id() + " needs a full solid block without a block entity: " + material;
|
||||
}
|
||||
} else if (isLake(choice.id())) {
|
||||
if (!BlockSymbolPages.isFluidMaterial(block.orElseThrow())) {
|
||||
return "Feature " + choice.id() + " needs a source-fluid block: " + material;
|
||||
}
|
||||
} else {
|
||||
return "Feature " + choice.id() + " cannot consume a block material page";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isRock(ResourceLocation choice) {
|
||||
return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "rocks"));
|
||||
}
|
||||
|
||||
public static boolean isLake(ResourceLocation choice) {
|
||||
return choice.getNamespace().equals("rivencraft") && List.of(
|
||||
"surface_lakes", "deep_lakes", "lava_lakes").contains(choice.getPath());
|
||||
}
|
||||
|
||||
public static boolean surfaceVegetation(ResourceLocation choice) {
|
||||
return choice.getNamespace().equals("rivencraft")
|
||||
&& List.of("flowers", "trees", "huge_trees").contains(choice.getPath());
|
||||
}
|
||||
|
||||
public static boolean caves(ResourceLocation choice) {
|
||||
return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "caves"));
|
||||
}
|
||||
|
||||
public static boolean ravines(ResourceLocation choice) {
|
||||
return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "ravines"));
|
||||
}
|
||||
|
||||
public static boolean supportsSurfaceVegetation(ResourceLocation id, RegistryAccess registries) {
|
||||
var biome = registries.lookupOrThrow(Registries.BIOME)
|
||||
.get(ResourceKey.create(Registries.BIOME, id));
|
||||
return biome.isPresent() && supportsSurfaceVegetation(biome.orElseThrow());
|
||||
}
|
||||
|
||||
public static boolean supportsSurfaceVegetation(Holder<Biome> biome) {
|
||||
return !biome.is(BiomeTags.IS_NETHER) && !biome.is(BiomeTags.IS_END)
|
||||
&& !biome.is(AgeBiomeSelection.CAVE_BIOMES);
|
||||
}
|
||||
|
||||
/** Fixed 1.21.1 mappings; data-pack symbols must opt in through a future extension. */
|
||||
public static ResourceLocation placedFeature(ResourceLocation choice) {
|
||||
if (!choice.getNamespace().equals("rivencraft")) return null;
|
||||
return switch (choice.getPath()) {
|
||||
case "flowers" -> ResourceLocation.withDefaultNamespace("flower_default");
|
||||
case "trees" -> ResourceLocation.withDefaultNamespace("trees_plains");
|
||||
case "huge_trees" -> ResourceLocation.withDefaultNamespace("trees_old_growth_spruce_taiga");
|
||||
case "rocks" -> ResourceLocation.withDefaultNamespace("forest_rock");
|
||||
case "surface_lakes", "lava_lakes" -> ResourceLocation.withDefaultNamespace("lake_lava_surface");
|
||||
case "deep_lakes" -> ResourceLocation.withDefaultNamespace("lake_lava_underground");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -65,6 +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);
|
||||
AgeGenerationPlan plan = new AgeGenerationPlan(CURRENT_VERSION, ageSeed, writing,
|
||||
parsed.symbols().stream().map(symbol -> new ResolvedSymbol(
|
||||
symbol.index(), symbol.reference(), symbol.semantics())).toList(),
|
||||
@@ -139,7 +140,7 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
|
||||
SymbolSemantics.Role role = SymbolSemantics.Role.valueOf(item.getString("Role"));
|
||||
String raw = item.getString("Value");
|
||||
SymbolSemantics.Value value = switch (role) {
|
||||
case TERRAIN, BIOME, FEATURE -> new SymbolSemantics.ResourceValue(ResourceLocation.parse(raw));
|
||||
case TERRAIN, BIOME, BLOCK, FEATURE -> new SymbolSemantics.ResourceValue(ResourceLocation.parse(raw));
|
||||
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER ->
|
||||
new SymbolSemantics.NumberValue(Double.parseDouble(raw));
|
||||
default -> new SymbolSemantics.ChoiceValue(raw);
|
||||
|
||||
@@ -30,7 +30,7 @@ public final class AgePersistenceGameTests {
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:weather/normal"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/normal"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:celestial/normal_time"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:feature/caves")))
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:feature/flowers")))
|
||||
));
|
||||
UUID id = UUID.randomUUID();
|
||||
AgeReference reference = new AgeReference(id, "Authored Plan " + id);
|
||||
|
||||
@@ -66,15 +66,20 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
ServerLevel overworld = level.getServer().overworld();
|
||||
AgeRegistrySavedData data = get(overworld);
|
||||
long seed = ageSeed(overworld.getSeed(), data.availableDimensionId(normalizedName(name)));
|
||||
AgeGenerationPlan.compile(writing, seed, AgeRuntimeProfiles.select(seed), AgeTraits.select(seed),
|
||||
AgeGenerationPlan.compile(writing, seed,
|
||||
AgeRuntimeProfiles.selectForWriting(seed, writing, overworld.registryAccess()), AgeTraits.select(seed),
|
||||
overworld.registryAccess());
|
||||
}
|
||||
|
||||
public static ResourceLocation previewGenerationProfile(ServerLevel level, String name) {
|
||||
return previewGenerationProfile(level, name, OrderedWriting.EMPTY);
|
||||
}
|
||||
|
||||
public static ResourceLocation previewGenerationProfile(ServerLevel level, String name, OrderedWriting writing) {
|
||||
ServerLevel overworld = level.getServer().overworld();
|
||||
AgeRegistrySavedData data = get(overworld);
|
||||
long seed = ageSeed(overworld.getSeed(), data.availableDimensionId(normalizedName(name)));
|
||||
return AgeRuntimeProfiles.select(seed);
|
||||
return AgeRuntimeProfiles.selectForWriting(seed, writing, overworld.registryAccess());
|
||||
}
|
||||
|
||||
public Optional<AgeDefinition> find(UUID ageId) {
|
||||
@@ -112,7 +117,7 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
String name = normalizedName(reference.name());
|
||||
ResourceLocation dimensionId = availableDimensionId(name);
|
||||
long seed = ageSeed(worldSeed, dimensionId);
|
||||
ResourceLocation seedProfile = AgeRuntimeProfiles.select(seed);
|
||||
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
|
||||
AgeTraits traits = AgeTraits.select(seed);
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
|
||||
AgeDefinition created = new AgeDefinition(
|
||||
|
||||
@@ -5,8 +5,12 @@ import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
@@ -59,6 +63,27 @@ public final class AgeRuntimeProfiles {
|
||||
return ids.get(Math.floorMod(Long.hashCode(seed), ids.size()));
|
||||
}
|
||||
|
||||
/** Keep seed-selected terrain random, but only among profiles able to host authored features. */
|
||||
public static ResourceLocation selectForWriting(long seed, OrderedWriting writing, RegistryAccess registries) {
|
||||
ResourceLocation selected = select(seed);
|
||||
WritingGrammar.Result parsed = WritingGrammar.parse(writing);
|
||||
if (parsed.symbols().stream().anyMatch(symbol ->
|
||||
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)) return selected;
|
||||
List<ResourceLocation> 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;
|
||||
AgeRuntimeProfile initial = PROFILES.get(selected);
|
||||
if (initial != null && features.stream().allMatch(id ->
|
||||
AgeFeatureSelection.error(id, initial, registries) == null)) return selected;
|
||||
List<ResourceLocation> compatible = PROFILES.entrySet().stream()
|
||||
.filter(entry -> features.stream().allMatch(id ->
|
||||
AgeFeatureSelection.error(id, entry.getValue(), registries) == null))
|
||||
.map(Map.Entry::getKey).sorted().toList();
|
||||
return compatible.isEmpty() ? selected
|
||||
: compatible.get(Math.floorMod(Long.hashCode(seed), compatible.size()));
|
||||
}
|
||||
|
||||
static AgeRuntimeProfile parse(ResourceLocation id, JsonElement json, RegistryAccess registries) {
|
||||
JsonObject object = json.getAsJsonObject();
|
||||
int schemaVersion = object.has("schema_version") ? object.get("schema_version").getAsInt() : 1;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.world.AgeFeatureChunkGenerator;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Issue #35: authored decorations must resolve to real placed features and survive plan reload. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class FeatureWritingGameTests {
|
||||
private FeatureWritingGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void featurePagesCompileToFrozenVanillaPlacements(GameTestHelper helper) {
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
Set<String> 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");
|
||||
helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(pages),
|
||||
"Supported feature pages must appear in the creative and JEI symbol feed");
|
||||
for (String path : pages) {
|
||||
helper.assertTrue(PageItem.isUsableWriting(PageItem.createBundledCreativePage(path)),
|
||||
path + " must be usable in authored writing");
|
||||
}
|
||||
OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/standard"),
|
||||
page("biome/plains"), page("biome/single"), page("feature/caves"), page("feature/flowers"),
|
||||
page("feature/trees"), page("feature/rocks"), page("feature/lava_lakes")));
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 87L, AgeGenerationProfiles.STANDARD,
|
||||
AgeTraits.select(87L), registries);
|
||||
List<AgeFeatureSelection.Choice> resolved = AgeFeatureSelection.resolved(plan);
|
||||
helper.assertValueEqual(resolved.stream().map(AgeFeatureSelection.Choice::id).toList(), List.of(
|
||||
id("caves"), id("flowers"), id("trees"), id("rocks"), id("lava_lakes")),
|
||||
"The compiled Age plan must keep feature order and exact semantic IDs");
|
||||
for (AgeFeatureSelection.Choice choice : resolved) {
|
||||
if (AgeFeatureSelection.isRock(choice.id()) || AgeFeatureSelection.isLake(choice.id())) {
|
||||
helper.assertTrue(choice.material().isPresent()
|
||||
&& AgeFeatureSelection.materialError(choice) == null,
|
||||
"Unmodified rocks and lakes must choose a valid material from the registry");
|
||||
}
|
||||
}
|
||||
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;
|
||||
var placement = AgeFeatureSelection.placedFeature(choice.id());
|
||||
helper.assertTrue(registries.lookupOrThrow(Registries.PLACED_FEATURE)
|
||||
.get(ResourceKey.create(Registries.PLACED_FEATURE, placement)).isPresent(),
|
||||
choice.id() + " must map to an existing vanilla placed feature");
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void unsupportedFeaturesAndTerrainsFailBeforeBinding(GameTestHelper helper) {
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
helper.assertTrue(AgeFeatureSelection.error(id("unknown"),
|
||||
AgeRuntimeProfiles.resolve(AgeGenerationProfiles.STANDARD), registries)
|
||||
.contains("Unsupported feature"),
|
||||
"Unimplemented feature meanings must report a diagnostic");
|
||||
rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/flat"), page("feature/flowers"))),
|
||||
"requires surface noise terrain");
|
||||
rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/flat"), page("feature/caves"))),
|
||||
"requires noise terrain");
|
||||
rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/cave"), page("feature/lava_lakes"))),
|
||||
"requires surface noise terrain");
|
||||
helper.assertTrue(AgeGenerationPlan.compile(new OrderedWriting(List.of(panel(),
|
||||
page("terrain/cave"), page("feature/caves"))), 87L, AgeGenerationProfiles.STANDARD,
|
||||
AgeTraits.select(87L), registries) != null,
|
||||
"Caves must add carving to Cave terrain as well as surface noise terrain");
|
||||
rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/standard"),
|
||||
page("biome/nether_wastes"), page("biome/single"), page("feature/trees"))),
|
||||
"surface Overworld biome");
|
||||
helper.assertTrue(AgeFeatureSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse(
|
||||
new OrderedWriting(List.of(panel(), page("terrain/standard"), page("feature/flowers")))),
|
||||
AgeRuntimeProfiles.resolve(AgeGenerationProfiles.STANDARD),
|
||||
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")),
|
||||
"A valid surface feature must still compile");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void blockPagesModifyFeaturesAndSurviveReload(GameTestHelper helper) {
|
||||
var stone = ResourceLocation.withDefaultNamespace("stone");
|
||||
var water = ResourceLocation.withDefaultNamespace("water");
|
||||
helper.assertTrue(BlockSymbolPages.availableBlocks().contains(stone),
|
||||
"Registry-derived material pages must include vanilla stone");
|
||||
helper.assertTrue(BlockSymbolPages.availableBlocks(BlockSymbolPages.CATEGORY).contains(stone),
|
||||
"Stone must appear among standard Block Pages");
|
||||
helper.assertTrue(!BlockSymbolPages.availableBlocks(BlockSymbolPages.CATEGORY).contains(water),
|
||||
"Water must not appear among standard Block Pages");
|
||||
helper.assertTrue(BlockSymbolPages.availableBlocks(BlockSymbolPages.FLUID_CATEGORY).contains(water),
|
||||
"Water must appear among Fluid Pages");
|
||||
for (String path : List.of("fern", "seagrass", "tube_coral")) {
|
||||
helper.assertTrue(!BlockSymbolPages.availableBlocks().contains(ResourceLocation.withDefaultNamespace(path)),
|
||||
path + " must not receive a rock or fluid material page");
|
||||
}
|
||||
var glowstone = ResourceLocation.withDefaultNamespace("glowstone");
|
||||
helper.assertTrue(BlockSymbolPages.availableBlocks(BlockSymbolPages.CATEGORY).contains(glowstone),
|
||||
"Glowstone should be a valid full-collision rock material");
|
||||
helper.assertTrue(AgeFeatureSelection.materialError(new AgeFeatureSelection.Choice(
|
||||
id("rocks"), Optional.of(glowstone))) == null,
|
||||
"Glowstone must bind successfully before Rocks");
|
||||
helper.assertTrue(!BlockSymbolPages.availableBlocks().contains(ResourceLocation.withDefaultNamespace("chest")),
|
||||
"Blocks with block entities must not receive generated pages");
|
||||
helper.assertTrue(!SymbolCatalog.isKnown(new SymbolReference(BlockSymbolPages.symbolId(
|
||||
ResourceLocation.withDefaultNamespace("chest")))),
|
||||
"Block-entity pages must not remain valid in the server catalog");
|
||||
helper.assertTrue(SymbolCatalog.isKnown(new SymbolReference(BlockSymbolPages.symbolId(stone))),
|
||||
"Generated material pages must be recognized by the server catalog");
|
||||
helper.assertTrue(PageItem.isUsableWriting(PageItem.createCreativeBlockPage(stone)),
|
||||
"A generated material page must be a usable authored page");
|
||||
OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/standard"),
|
||||
page("biome/plains"), page("biome/single"), block("stone"), page("feature/rocks"),
|
||||
block("water"), page("feature/lava_lakes"), page("feature/huge_trees"),
|
||||
page("feature/ravines")));
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 87L, AgeGenerationProfiles.STANDARD,
|
||||
AgeTraits.select(87L), helper.getLevel().registryAccess());
|
||||
List<AgeFeatureSelection.Choice> expected = List.of(
|
||||
new AgeFeatureSelection.Choice(id("rocks"), Optional.of(stone)),
|
||||
new AgeFeatureSelection.Choice(id("lava_lakes"), Optional.of(water)),
|
||||
choice("huge_trees"), choice("ravines"));
|
||||
helper.assertValueEqual(AgeFeatureSelection.resolved(plan), expected,
|
||||
"Each material must attach only to the immediately following feature");
|
||||
helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), expected,
|
||||
"Block modifiers must survive the frozen-plan round trip");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void incompatibleAndMisplacedBlockPagesAreRejected(GameTestHelper helper) {
|
||||
helper.assertTrue(AgeFeatureSelection.materialError(new AgeFeatureSelection.Choice(
|
||||
id("rocks"), Optional.of(ResourceLocation.withDefaultNamespace("torch"))))
|
||||
.contains("full solid block"),
|
||||
"A non-full block must still be rejected if supplied by old writing");
|
||||
rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/standard"),
|
||||
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");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 600)
|
||||
public static void runtimeAgeUsesSavedFeatureGenerator(GameTestHelper helper) {
|
||||
var player = helper.makeMockServerPlayerInLevel();
|
||||
OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/standard"),
|
||||
page("biome/plains"), page("biome/single"), page("feature/caves"), page("feature/rocks")));
|
||||
AgeReference reference = new AgeReference(UUID.randomUUID(), "Written Flowers");
|
||||
var target = AgeDimensionService.resolve(player, reference, writing).orElseThrow();
|
||||
AgeDefinition definition = AgeRegistrySavedData.get(helper.getLevel()).find(reference.id()).orElseThrow();
|
||||
List<AgeFeatureSelection.Choice> expected = AgeFeatureSelection.resolved(definition.plan());
|
||||
helper.assertValueEqual(expected.stream().map(AgeFeatureSelection.Choice::id).toList(),
|
||||
List.of(id("caves"), id("rocks")), "Saved Age definition must retain the written decoration");
|
||||
helper.assertTrue(expected.get(1).material().isPresent(),
|
||||
"Unmodified rocks must receive a chosen material");
|
||||
helper.runAfterDelay(1, () -> {
|
||||
var level = helper.getLevel().getServer().getLevel(target.location().dimension());
|
||||
helper.assertTrue(level != null, "The authored feature Age must load");
|
||||
helper.assertTrue(level.getChunkSource().getGenerator() instanceof AgeFeatureChunkGenerator,
|
||||
"Runtime world generation must use the feature-aware generator");
|
||||
var generator = (AgeFeatureChunkGenerator) level.getChunkSource().getGenerator();
|
||||
helper.assertValueEqual(generator.ageFeatures(), expected,
|
||||
"Runtime generation must receive the frozen authored feature selection");
|
||||
helper.succeed();
|
||||
});
|
||||
}
|
||||
|
||||
private static void rejects(GameTestHelper helper, OrderedWriting writing, String expected) {
|
||||
try {
|
||||
AgeGenerationPlan.compile(writing, 87L, AgeGenerationProfiles.STANDARD,
|
||||
AgeTraits.select(87L), helper.getLevel().registryAccess());
|
||||
helper.fail("Writing must reject " + expected);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
helper.assertTrue(exception.getMessage().contains(expected), exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static ResourceLocation id(String path) {
|
||||
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
|
||||
private static AgeFeatureSelection.Choice choice(String path) {
|
||||
return new AgeFeatureSelection.Choice(id(path), java.util.Optional.empty());
|
||||
}
|
||||
|
||||
private static WritingEntry page(String path) {
|
||||
return WritingEntry.symbol(new SymbolReference(id(path)));
|
||||
}
|
||||
|
||||
private static WritingEntry block(String path) {
|
||||
return WritingEntry.symbol(new SymbolReference(BlockSymbolPages.symbolId(
|
||||
ResourceLocation.withDefaultNamespace(path))));
|
||||
}
|
||||
|
||||
private static WritingEntry panel() { return WritingEntry.linkPanel(); }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDefinition;
|
||||
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
|
||||
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
@@ -55,12 +56,19 @@ public final class PageItem extends Item {
|
||||
|
||||
/** Bundled categories become separate creative tabs without a Java page list. */
|
||||
public static List<ResourceLocation> bundledCreativeCategories() {
|
||||
return List.copyOf(CREATIVE_SYMBOLS.values().stream()
|
||||
LinkedHashSet<ResourceLocation> categories = CREATIVE_SYMBOLS.values().stream()
|
||||
.map(CreativeSymbol::creativeCategory)
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)));
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
categories.add(BlockSymbolPages.CATEGORY);
|
||||
categories.add(BlockSymbolPages.FLUID_CATEGORY);
|
||||
return List.copyOf(categories);
|
||||
}
|
||||
|
||||
public static ItemStack bundledCreativeCategoryIcon(ResourceLocation category) {
|
||||
if (category.equals(BlockSymbolPages.CATEGORY) || category.equals(BlockSymbolPages.FLUID_CATEGORY)) {
|
||||
return createCreativeBlockPage(ResourceLocation.withDefaultNamespace(
|
||||
category.equals(BlockSymbolPages.FLUID_CATEGORY) ? "water" : "stone"));
|
||||
}
|
||||
return CREATIVE_SYMBOLS.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().creativeCategory().equals(category))
|
||||
.findFirst()
|
||||
@@ -70,6 +78,12 @@ public final class PageItem extends Item {
|
||||
|
||||
/** Exact symbol-page variants offered in the creative tab and to optional ingredient viewers. */
|
||||
public static List<ItemStack> creativeSymbolPages(HolderLookup.Provider registries) {
|
||||
List<ItemStack> pages = creativeBundledAndBiomePages(registries);
|
||||
BlockSymbolPages.availableBlocks().forEach(id -> pages.add(createCreativeBlockPage(id)));
|
||||
return List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static List<ItemStack> creativeBundledAndBiomePages(HolderLookup.Provider registries) {
|
||||
List<ItemStack> pages = new ArrayList<>();
|
||||
CREATIVE_SYMBOLS.keySet().forEach(id -> pages.add(createBundledCreativePage(id)));
|
||||
Set<String> bundled = CREATIVE_SYMBOLS.keySet();
|
||||
@@ -79,12 +93,16 @@ public final class PageItem extends Item {
|
||||
.filter(id -> !bundled.contains("biome/" + id.getPath()))
|
||||
.sorted()
|
||||
.forEach(id -> pages.add(createCreativeBiomePage(id)));
|
||||
return List.copyOf(pages);
|
||||
return pages;
|
||||
}
|
||||
|
||||
public static List<ItemStack> creativeSymbolPages(HolderLookup.Provider registries,
|
||||
ResourceLocation category) {
|
||||
return creativeSymbolPages(registries).stream()
|
||||
if (category.equals(BlockSymbolPages.CATEGORY) || category.equals(BlockSymbolPages.FLUID_CATEGORY)) {
|
||||
return BlockSymbolPages.availableBlocks(category).stream()
|
||||
.map(PageItem::createCreativeBlockPage).toList();
|
||||
}
|
||||
return creativeBundledAndBiomePages(registries).stream()
|
||||
.filter(page -> category.equals(creativeCategory(page)))
|
||||
.toList();
|
||||
}
|
||||
@@ -119,6 +137,13 @@ public final class PageItem extends Item {
|
||||
return page;
|
||||
}
|
||||
|
||||
public static ItemStack createCreativeBlockPage(ResourceLocation blockId) {
|
||||
var symbolId = BlockSymbolPages.symbolId(blockId);
|
||||
ItemStack page = create(PageContent.symbol(new SymbolReference(symbolId)));
|
||||
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), BlockSymbolPages.definition(blockId).display(symbolId));
|
||||
return page;
|
||||
}
|
||||
|
||||
private static Map<String, CreativeSymbol> loadCreativeSymbols() {
|
||||
String resource = "/assets/rivencraft/creative_symbol_pages.json";
|
||||
try (var stream = PageItem.class.getResourceAsStream(resource)) {
|
||||
@@ -233,6 +258,10 @@ public final class PageItem extends Item {
|
||||
&& reference.id().getPath().startsWith("biome/")
|
||||
? Component.translatableWithFallback(metadata.nameTranslationKey(),
|
||||
BiomeSymbolPages.fallbackName(reference.id()))
|
||||
: reference.id().getNamespace().equals(Rivencraft.MOD_ID)
|
||||
&& reference.id().getPath().startsWith("block/")
|
||||
? Component.translatableWithFallback(metadata.nameTranslationKey(),
|
||||
BlockSymbolPages.fallbackName(BlockSymbolPages.blockId(reference.id())))
|
||||
: Component.translatable(metadata.nameTranslationKey());
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.registry;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.world.AgeFlatChunkGenerator;
|
||||
import net.dnorberg.rivencraft.world.AgeFeatureChunkGenerator;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
@@ -13,7 +14,10 @@ public final class ModChunkGenerators {
|
||||
private static final DeferredRegister<MapCodec<? extends ChunkGenerator>> GENERATORS =
|
||||
DeferredRegister.create(Registries.CHUNK_GENERATOR, Rivencraft.MOD_ID);
|
||||
|
||||
static { GENERATORS.register("age_flat", () -> AgeFlatChunkGenerator.CODEC); }
|
||||
static {
|
||||
GENERATORS.register("age_flat", () -> AgeFlatChunkGenerator.CODEC);
|
||||
GENERATORS.register("age_features", () -> AgeFeatureChunkGenerator.CODEC);
|
||||
}
|
||||
|
||||
private ModChunkGenerators() { }
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ public final class ModCreativeTabs {
|
||||
.build());
|
||||
|
||||
private static final List<String> CATEGORY_ORDER = List.of(
|
||||
"terrain", "biome", "biome_generation", "weather", "lighting", "sky", "celestial", "feature");
|
||||
"terrain", "biome", "biome_generation", "weather", "lighting", "sky", "celestial", "block", "fluid", "feature");
|
||||
|
||||
public static final Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> SYMBOL_PAGES =
|
||||
registerSymbolPageTabs();
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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 net.dnorberg.rivencraft.book.AgeFeatureSelection;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.QuartPos;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.LegacyRandomSource;
|
||||
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.carver.CarvingContext;
|
||||
import net.minecraft.world.level.levelgen.carver.ConfiguredWorldCarver;
|
||||
import net.minecraft.world.level.levelgen.placement.BiomeFilter;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import net.minecraft.world.level.levelgen.placement.RarityFilter;
|
||||
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
import net.minecraft.world.level.levelgen.feature.LakeFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.configurations.BlockStateConfiguration;
|
||||
import net.minecraft.world.level.levelgen.feature.stateproviders.BlockStateProvider;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
/** Adds frozen authored decorations after vanilla's biome features, without changing shared biome registries. */
|
||||
public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
||||
public static final MapCodec<AgeFeatureChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(instance ->
|
||||
instance.group(BiomeSource.CODEC.fieldOf("biome_source").forGetter(generator -> generator.getBiomeSource()),
|
||||
NoiseGeneratorSettings.CODEC.fieldOf("settings").forGetter(generator -> generator.settings),
|
||||
AgeFeatureSelection.Choice.CODEC.listOf().fieldOf("age_features").forGetter(generator -> generator.features),
|
||||
Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed))
|
||||
.apply(instance, AgeFeatureChunkGenerator::new));
|
||||
|
||||
private final Holder<NoiseGeneratorSettings> settings;
|
||||
private final List<AgeFeatureSelection.Choice> features;
|
||||
private final long ageSeed;
|
||||
|
||||
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
|
||||
List<AgeFeatureSelection.Choice> features, long ageSeed) {
|
||||
super(biomes, settings);
|
||||
this.settings = settings;
|
||||
this.features = List.copyOf(features);
|
||||
this.ageSeed = ageSeed;
|
||||
}
|
||||
|
||||
public List<AgeFeatureSelection.Choice> ageFeatures() { return features; }
|
||||
|
||||
@Override
|
||||
protected MapCodec<? extends ChunkGenerator> codec() { return CODEC; }
|
||||
|
||||
@Override
|
||||
public void applyCarvers(WorldGenRegion level, long seed, RandomState random, BiomeManager biomes,
|
||||
StructureManager structures, ChunkAccess chunk, GenerationStep.Carving step) {
|
||||
super.applyCarvers(level, seed, random, biomes, structures, chunk, step);
|
||||
if (step != GenerationStep.Carving.AIR || features.stream().noneMatch(choice ->
|
||||
AgeFeatureSelection.caves(choice.id()) || AgeFeatureSelection.ravines(choice.id()))) return;
|
||||
var noiseChunk = chunk.getOrCreateNoiseChunk(ignored -> {
|
||||
throw new IllegalStateException("Vanilla carver did not initialize the Age noise chunk");
|
||||
});
|
||||
var context = new CarvingContext(this, level.registryAccess(), chunk.getHeightAccessorForGeneration(),
|
||||
noiseChunk, random, settings.value().surfaceRule());
|
||||
var mask = ((ProtoChunk) chunk).getOrCreateCarvingMask(step);
|
||||
var carvers = level.registryAccess().lookupOrThrow(Registries.CONFIGURED_CARVER);
|
||||
var sourceRandom = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
ChunkPos target = chunk.getPos();
|
||||
BiomeManager sourceBiomes = biomes.withDifferentSource((qx, qy, qz) ->
|
||||
getBiomeSource().getNoiseBiome(qx, qy, qz, random.sampler()));
|
||||
for (AgeFeatureSelection.Choice choice : features) {
|
||||
if (!AgeFeatureSelection.caves(choice.id()) && !AgeFeatureSelection.ravines(choice.id())) continue;
|
||||
for (int dx = -8; dx <= 8; dx++) {
|
||||
for (int dz = -8; dz <= 8; dz++) {
|
||||
ChunkPos source = new ChunkPos(target.x + dx, target.z + dz);
|
||||
var biome = getBiomeSource().getNoiseBiome(QuartPos.fromBlock(source.getMinBlockX()), 0,
|
||||
QuartPos.fromBlock(source.getMinBlockZ()), random.sampler());
|
||||
if (AgeFeatureSelection.ravines(choice.id())
|
||||
&& biome.is(net.minecraft.tags.BiomeTags.IS_NETHER)) continue;
|
||||
ResourceLocation carverId = ResourceLocation.withDefaultNamespace(
|
||||
AgeFeatureSelection.ravines(choice.id()) ? "canyon"
|
||||
: biome.is(net.minecraft.tags.BiomeTags.IS_NETHER) ? "nether_cave" : "cave");
|
||||
ConfiguredWorldCarver<?> carver = carvers.getOrThrow(
|
||||
ResourceKey.create(Registries.CONFIGURED_CARVER, carverId)).value();
|
||||
sourceRandom.setLargeFeatureSeed(ageSeed ^ (AgeFeatureSelection.ravines(choice.id())
|
||||
? 0x524156494E45534CL : 0x4341564553464541L), source.x, source.z);
|
||||
if (carver.isStartChunk(sourceRandom)) {
|
||||
carver.carve(context, chunk, sourceBiomes::getBiome, sourceRandom,
|
||||
noiseChunk.aquifer(), source, mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structures) {
|
||||
super.applyBiomeDecoration(level, chunk, structures);
|
||||
var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
|
||||
int x = chunk.getPos().getMinBlockX();
|
||||
int z = chunk.getPos().getMinBlockZ();
|
||||
BlockPos origin = new BlockPos(x, level.getMinBuildHeight(), z);
|
||||
for (int index = 0; index < features.size(); index++) {
|
||||
AgeFeatureSelection.Choice choice = features.get(index);
|
||||
if (AgeFeatureSelection.surfaceVegetation(choice.id())) {
|
||||
int surface = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 8);
|
||||
if (!AgeFeatureSelection.supportsSurfaceVegetation(
|
||||
level.getBiome(new BlockPos(x + 8, surface, z + 8)))) continue;
|
||||
}
|
||||
ResourceLocation key = AgeFeatureSelection.placedFeature(choice.id());
|
||||
if (key == null) continue; // Carvers are applied before decoration.
|
||||
PlacedFeature vanilla = registry.getOrThrow(ResourceKey.create(Registries.PLACED_FEATURE, key)).value();
|
||||
// Vanilla's biome filter only admits a feature already in that biome's list. An
|
||||
// authored page intentionally adds it, so preserve all other vanilla placement rules.
|
||||
Holder<ConfiguredFeature<?, ?>> configured = vanilla.feature();
|
||||
if (choice.material().isPresent()) {
|
||||
var material = BuiltInRegistries.BLOCK.get(choice.material().orElseThrow()).defaultBlockState();
|
||||
if (AgeFeatureSelection.isRock(choice.id())) {
|
||||
configured = Holder.direct(new ConfiguredFeature<>(Feature.FOREST_ROCK,
|
||||
new BlockStateConfiguration(material)));
|
||||
} else if (AgeFeatureSelection.isLake(choice.id())) {
|
||||
configured = Holder.direct(new ConfiguredFeature<>(Feature.LAKE,
|
||||
new LakeFeature.Configuration(BlockStateProvider.simple(material),
|
||||
BlockStateProvider.simple(Blocks.STONE))));
|
||||
}
|
||||
}
|
||||
PlacedFeature authored = new PlacedFeature(configured, vanilla.placement().stream()
|
||||
.filter(modifier -> !(modifier instanceof BiomeFilter))
|
||||
.map(modifier -> AgeFeatureSelection.isLake(choice.id())
|
||||
&& !choice.id().getPath().equals("deep_lakes")
|
||||
&& modifier instanceof RarityFilter
|
||||
? RarityFilter.onAverageOnceEvery(12) : modifier)
|
||||
.toList());
|
||||
long seed = ageSeed ^ (long) x * 341873128712L ^ (long) z * 132897987541L
|
||||
^ (long) choice.hashCode() * 0x9E3779B97F4A7C15L;
|
||||
authored.place(level, this, RandomSource.create(seed), origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.level.EmptyBlockGetter;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.EntityBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Registry-derived material pages; binding validates the material for its consumer. */
|
||||
public final class BlockSymbolPages {
|
||||
public static final ResourceLocation CATEGORY = ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "block");
|
||||
public static final ResourceLocation FLUID_CATEGORY = ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "fluid");
|
||||
|
||||
private BlockSymbolPages() {}
|
||||
|
||||
public static ResourceLocation symbolId(ResourceLocation blockId) {
|
||||
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
|
||||
"block/" + blockId.getNamespace() + "/" + blockId.getPath());
|
||||
}
|
||||
|
||||
public static ResourceLocation blockId(ResourceLocation symbolId) {
|
||||
String path = symbolId.getPath();
|
||||
if (!path.startsWith("block/")) return null;
|
||||
String rest = path.substring("block/".length());
|
||||
int separator = rest.indexOf('/');
|
||||
return separator < 1 ? null : ResourceLocation.tryParse(
|
||||
rest.substring(0, separator) + ":" + rest.substring(separator + 1));
|
||||
}
|
||||
|
||||
public static java.util.List<ResourceLocation> availableBlocks() {
|
||||
return BuiltInRegistries.BLOCK.keySet().stream()
|
||||
.filter(id -> isRockMaterial(BuiltInRegistries.BLOCK.get(id))
|
||||
|| isFluidMaterial(BuiltInRegistries.BLOCK.get(id)))
|
||||
.sorted().toList();
|
||||
}
|
||||
|
||||
public static java.util.List<ResourceLocation> availableBlocks(ResourceLocation category) {
|
||||
if (!category.equals(CATEGORY) && !category.equals(FLUID_CATEGORY)) return java.util.List.of();
|
||||
return availableBlocks().stream().filter(id -> category(id).equals(category)).toList();
|
||||
}
|
||||
|
||||
public static ResourceLocation category(ResourceLocation blockId) {
|
||||
return isFluidMaterial(BuiltInRegistries.BLOCK.get(blockId)) ? FLUID_CATEGORY : CATEGORY;
|
||||
}
|
||||
|
||||
public static boolean isRockMaterial(Block block) {
|
||||
if (excluded(block)) return false;
|
||||
BlockState state = block.defaultBlockState();
|
||||
return state.getFluidState().isEmpty()
|
||||
&& state.isCollisionShapeFullBlock(EmptyBlockGetter.INSTANCE, BlockPos.ZERO);
|
||||
}
|
||||
|
||||
public static boolean isFluidMaterial(Block block) {
|
||||
if (excluded(block)) return false;
|
||||
var fluid = block.defaultBlockState().getFluidState();
|
||||
// A waterlogged coral or seagrass block contains water, but is not a fluid block.
|
||||
return fluid.isSource() && fluid.createLegacyBlock().getBlock() == block;
|
||||
}
|
||||
|
||||
private static boolean excluded(Block block) {
|
||||
return block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR
|
||||
|| block instanceof EntityBlock || block.defaultBlockState().hasBlockEntity();
|
||||
}
|
||||
|
||||
public static SymbolDefinition definition(ResourceLocation blockId) {
|
||||
String path = blockId.getPath().replace('/', '_');
|
||||
String word = Arrays.stream(path.split("_"))
|
||||
.map(part -> part.replaceAll("[^A-Za-z]", ""))
|
||||
.filter(part -> !part.isEmpty())
|
||||
.map(part -> part.substring(0, 1).toUpperCase(Locale.ROOT) + part.substring(1))
|
||||
.reduce("", String::concat);
|
||||
if (word.isEmpty()) word = "Stone";
|
||||
// A modded registry path can be arbitrarily long; poems have a bounded codec.
|
||||
if (word.length() > 48) word = word.substring(0, 48);
|
||||
return new SymbolDefinition(SymbolDefinition.CURRENT_SCHEMA_VERSION,
|
||||
"block." + blockId.getNamespace() + "." + path,
|
||||
"symbol.rivencraft.block.generated.description", category(blockId),
|
||||
Optional.of(new SymbolSemantics(SymbolSemantics.Role.BLOCK,
|
||||
new SymbolSemantics.ResourceValue(blockId))),
|
||||
"Modifier Constraint " + word + " Form");
|
||||
}
|
||||
|
||||
public static String fallbackName(ResourceLocation blockId) {
|
||||
return Arrays.stream(blockId.getPath().replace('/', '_').split("_"))
|
||||
.filter(part -> !part.isEmpty())
|
||||
.map(part -> part.substring(0, 1).toUpperCase(Locale.ROOT) + part.substring(1))
|
||||
.reduce((left, right) -> left + " " + right).orElse("Unknown")
|
||||
+ (category(blockId).equals(FLUID_CATEGORY) ? " Fluid" : " Material");
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,8 @@ public final class SymbolCatalog {
|
||||
.filter(id -> id.getNamespace().equals("minecraft"))
|
||||
.forEach(id -> loaded.putIfAbsent(BiomeSymbolPages.symbolId(id),
|
||||
BiomeSymbolPages.definition(id)));
|
||||
BlockSymbolPages.availableBlocks().forEach(id -> loaded.putIfAbsent(
|
||||
BlockSymbolPages.symbolId(id), BlockSymbolPages.definition(id)));
|
||||
symbols = Map.copyOf(loaded);
|
||||
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ public record SymbolSemantics(Role role, Value value) {
|
||||
PERIOD_MODIFIER("period_modifier", Kind.NUMBER),
|
||||
ANGLE_MODIFIER("angle_modifier", Kind.NUMBER),
|
||||
PHASE_MODIFIER("phase_modifier", Kind.NUMBER),
|
||||
BLOCK("block", Kind.RESOURCE),
|
||||
FEATURE("feature", Kind.RESOURCE);
|
||||
|
||||
private final String id;
|
||||
|
||||
@@ -41,6 +41,7 @@ public final class WritingGrammar {
|
||||
int panels = 0;
|
||||
int lastStage = -1;
|
||||
int biomeCount = 0;
|
||||
ParsedSymbol pendingBlock = null;
|
||||
for (int index = 0; index < writing.entries().size(); index++) {
|
||||
WritingEntry entry = writing.entries().get(index);
|
||||
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
|
||||
@@ -69,6 +70,22 @@ public final class WritingGrammar {
|
||||
Role role = semantics.orElseThrow().role();
|
||||
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"));
|
||||
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")
|
||||
.contains(feature.getPath()) || !feature.getNamespace().equals("rivencraft")) {
|
||||
diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingBlock.index(),
|
||||
"This feature cannot consume a material page"));
|
||||
}
|
||||
pendingBlock = null;
|
||||
}
|
||||
if (role == Role.ANGLE_MODIFIER || role == Role.WEATHER
|
||||
&& parsed.semantics().value() instanceof ChoiceValue choice
|
||||
&& Set.of("snow", "cloudy", "rain").contains(choice.id())) {
|
||||
@@ -99,6 +116,8 @@ 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);
|
||||
@@ -152,14 +171,14 @@ public final class WritingGrammar {
|
||||
case SKY -> 5;
|
||||
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER -> 6;
|
||||
case CELESTIAL -> 7;
|
||||
case FEATURE -> 8;
|
||||
case BLOCK, FEATURE -> 8;
|
||||
};
|
||||
}
|
||||
|
||||
public enum Code {
|
||||
TOO_MANY, MISSING_PANEL, PANEL_ORDER, DUPLICATE_PANEL, UNRESOLVED, UNSUPPORTED,
|
||||
DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, INCOMPLETE, UNSUPPORTED_PROFILE,
|
||||
UNKNOWN_BIOME, UNSUPPORTED_BIOME, UNSUPPORTED_ENVIRONMENT
|
||||
UNKNOWN_BIOME, UNSUPPORTED_BIOME, UNSUPPORTED_ENVIRONMENT, UNSUPPORTED_FEATURE
|
||||
}
|
||||
|
||||
public record Diagnostic(Code code, int index, String message) {
|
||||
|
||||
@@ -208,7 +208,24 @@
|
||||
"symbol.rivencraft.phase.setting": "Setting Phase",
|
||||
"symbol.rivencraft.phase.setting.description": "Set the celestial phase to setting.",
|
||||
"symbol.rivencraft.feature.caves": "Caves",
|
||||
"symbol.rivencraft.feature.caves.description": "Describe underground cave features.",
|
||||
"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.feature.flowers": "Flowers",
|
||||
"symbol.rivencraft.feature.flowers.description": "Add vanilla flower patches to surface terrain.",
|
||||
"symbol.rivencraft.feature.trees": "Trees",
|
||||
"symbol.rivencraft.feature.trees.description": "Add vanilla plains trees to surface terrain.",
|
||||
"symbol.rivencraft.feature.rocks": "Rocks",
|
||||
"symbol.rivencraft.feature.rocks.description": "Add boulders using a random solid material, or specify one with a block page.",
|
||||
"symbol.rivencraft.feature.huge_trees": "Huge Trees",
|
||||
"symbol.rivencraft.feature.huge_trees.description": "Add vanilla old-growth spruce trees to surface terrain.",
|
||||
"symbol.rivencraft.feature.surface_lakes": "Surface Lakes",
|
||||
"symbol.rivencraft.feature.surface_lakes.description": "Add surface lakes using a random fluid, or specify one with a fluid page.",
|
||||
"symbol.rivencraft.feature.deep_lakes": "Deep Lakes",
|
||||
"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.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",
|
||||
"generator.rivencraft.amplified": "Amplified",
|
||||
"generator.rivencraft.island": "Island",
|
||||
@@ -238,5 +255,7 @@
|
||||
"itemGroup.rivencraft.pages.lighting": "Rivencraft Lighting Pages",
|
||||
"itemGroup.rivencraft.pages.sky": "Rivencraft Sky Pages",
|
||||
"itemGroup.rivencraft.pages.celestial": "Rivencraft Celestial Pages",
|
||||
"itemGroup.rivencraft.pages.block": "Rivencraft Block Pages",
|
||||
"itemGroup.rivencraft.pages.fluid": "Rivencraft Fluid Pages",
|
||||
"itemGroup.rivencraft.pages.feature": "Rivencraft Feature Pages"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.feature.deep_lakes",
|
||||
"description": "symbol.rivencraft.feature.deep_lakes.description",
|
||||
"category": "rivencraft:feature",
|
||||
"semantics": { "role": "feature", "value": "rivencraft:deep_lakes" },
|
||||
"poem": "Nature Flow Static Explore"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.feature.flowers",
|
||||
"description": "symbol.rivencraft.feature.flowers.description",
|
||||
"category": "rivencraft:feature",
|
||||
"semantics": { "role": "feature", "value": "rivencraft:flowers" },
|
||||
"poem": "Nature Grow Color Bloom"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.feature.huge_trees",
|
||||
"description": "symbol.rivencraft.feature.huge_trees.description",
|
||||
"category": "rivencraft:feature",
|
||||
"semantics": { "role": "feature", "value": "rivencraft:huge_trees" },
|
||||
"poem": "Nature Stimulate Spur Elevate"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.feature.lava_lakes",
|
||||
"description": "symbol.rivencraft.feature.lava_lakes.description",
|
||||
"category": "rivencraft:feature",
|
||||
"semantics": { "role": "feature", "value": "rivencraft:lava_lakes" },
|
||||
"poem": "Fire Fluid Basin Transform"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.feature.ravines",
|
||||
"description": "symbol.rivencraft.feature.ravines.description",
|
||||
"category": "rivencraft:feature",
|
||||
"semantics": { "role": "feature", "value": "rivencraft:ravines" },
|
||||
"poem": "Terrain Transform Void Weave"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.feature.rocks",
|
||||
"description": "symbol.rivencraft.feature.rocks.description",
|
||||
"category": "rivencraft:feature",
|
||||
"semantics": { "role": "feature", "value": "rivencraft:rocks" },
|
||||
"poem": "Earth Form Moss Stone"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.feature.surface_lakes",
|
||||
"description": "symbol.rivencraft.feature.surface_lakes.description",
|
||||
"category": "rivencraft:feature",
|
||||
"semantics": { "role": "feature", "value": "rivencraft:surface_lakes" },
|
||||
"poem": "Nature Flow Static Elevate"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"name": "symbol.rivencraft.feature.trees",
|
||||
"description": "symbol.rivencraft.feature.trees.description",
|
||||
"category": "rivencraft:feature",
|
||||
"semantics": { "role": "feature", "value": "rivencraft:trees" },
|
||||
"poem": "Nature Grow Life Forest"
|
||||
}
|
||||
Reference in new issue
Block a user