Compare commits

..
2 Commits
Author SHA1 Message Date
dnorberg 4f07f675a4 Refresh public links and include development screenshots
Build / build (push) Canceled after 0s
2026-10-26 14:25:18 -04:00
dnorberg b8be99606f Add configurable random Page lists and safe fallbacks (#95) 2026-10-26 14:25:13 -04:00
33 changed files with 394 additions and 40 deletions

No files matched your search

+4 -1
View File
@@ -95,7 +95,10 @@ to control them; only OPs see the diagnostics even when enabled.
This repository is the development source. See the [current implementation](docs/CURRENT_IMPLEMENTATION.md),
[milestone summaries](docs/MILESTONES.md), [technical document index](docs/README.md),
and [issues](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues).
and [issues](https://git.paddedcell.org/dnorberg/rivencraft/issues).
Help build the [Rivencraft wiki](https://git.paddedcell.org/dnorberg/rivencraft/wiki)
with guides for Ages, pages, and crafting, or open an issue with documentation
suggestions and corrections.
Milestone 7 is complete in 1.0.0.2. Its [acceptance record](docs/MILESTONE_7_ACCEPTANCE.md)
and [regression matrix](docs/MILESTONE_7_REGRESSION.md) distinguish automated
checks from manual evidence and known limitations.
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

+1 -1
View File
@@ -36,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=1.0.0.2
mod_version=1.0.1.1
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=pHEgQQUE
@@ -8,6 +8,7 @@ import java.util.Random;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.core.Holder;
@@ -34,6 +35,12 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
public static AgeBiomeSelection compile(WritingGrammar.Result writing, AgeTraits traits,
AgeRuntimeProfile profile, long seed, RegistryAccess registries) {
return compile(writing, traits, profile, seed, registries, RandomPagePolicy.UNRESTRICTED);
}
public static AgeBiomeSelection compile(WritingGrammar.Result writing, AgeTraits traits,
AgeRuntimeProfile profile, long seed, RegistryAccess registries,
RandomPagePolicy policy) {
List<ResourceLocation> authored = writing.symbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.BIOME)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList();
@@ -67,10 +74,26 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
+ " or a TerraBlender Nether region");
}
}
List<ResourceLocation> availableBiomes = eligible(registries, profile);
if (availableBiomes.isEmpty()) throw new IllegalArgumentException("No eligible biomes for terrain " + profile.noiseSettings());
List<ResourceLocation> terrainBiomes = eligible(registries, profile);
if (terrainBiomes.isEmpty()) throw new IllegalArgumentException("No eligible biomes for terrain " + profile.noiseSettings());
List<ResourceLocation> availableBiomes = distribution == AgeTraits.BiomeDistribution.NATIVE
? terrainBiomes : new ArrayList<>(policy.eligible(terrainBiomes, BiomeSymbolPages::symbolId));
if (distribution != AgeTraits.BiomeDistribution.NATIVE
&& new LinkedHashSet<>(availableBiomes).size() < minimum(distribution)) {
ResourceLocation preferred = profile.terrain() == AgeRuntimeProfile.Terrain.VOID
? ResourceLocation.withDefaultNamespace("the_void")
: cave ? ResourceLocation.withDefaultNamespace("deep_dark")
: ResourceLocation.withDefaultNamespace("plains");
if (terrainBiomes.contains(preferred) && !availableBiomes.contains(preferred))
availableBiomes.add(preferred);
for (ResourceLocation fallback : terrainBiomes) {
if (new LinkedHashSet<>(availableBiomes).size() >= minimum(distribution)) break;
if (!availableBiomes.contains(fallback)) availableBiomes.add(fallback);
}
}
if (distribution == AgeTraits.BiomeDistribution.NATIVE) {
if (!authored.isEmpty()) throw new IllegalArgumentException("Native biome distribution cannot use biome pages");
// Native noise biomes are supplied by Minecraft's preset, not chosen as Pages.
return new AgeBiomeSelection(distribution, profile.terrain() == AgeRuntimeProfile.Terrain.FLAT
? List.of(ResourceLocation.withDefaultNamespace("plains"))
: isCave(profile) ? availableBiomes : presetBiomes(registries, profile));
@@ -23,7 +23,8 @@ public record AgeDefinition(
@Nullable AgeSeasonMode seasonMode,
List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability,
AgeInstabilityPlan instabilityPlan
AgeInstabilityPlan instabilityPlan,
RandomPagePolicy randomPagePolicy
) {
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "random_vanilla"
@@ -39,6 +40,7 @@ public record AgeDefinition(
Objects.requireNonNull(traits, "traits");
Objects.requireNonNull(instability, "instability");
Objects.requireNonNull(instabilityPlan, "instabilityPlan");
Objects.requireNonNull(randomPagePolicy, "randomPagePolicy");
randomFeatures = List.copyOf(randomFeatures);
if (plan != null && !randomFeatures.isEmpty()) {
throw new IllegalArgumentException("Authored Ages cannot also have random features");
@@ -88,6 +90,16 @@ public record AgeDefinition(
AgeInstabilityPlan.empty());
}
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, List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability, AgeInstabilityPlan instabilityPlan) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, instability,
instabilityPlan, RandomPagePolicy.UNRESTRICTED);
}
public static ResourceLocation dimensionId(UUID ageId) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + ageId);
}
@@ -117,7 +129,7 @@ public record AgeDefinition(
public AgeDefinition withSpawn(BlockPos spawn) {
return new AgeDefinition(id, name, seed, generationProfile, generationVersion,
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
randomFeatures, instability, instabilityPlan);
randomFeatures, instability, instabilityPlan, randomPagePolicy);
}
/** A frozen authored profile takes precedence over the profile ID on old books. */
@@ -465,7 +465,7 @@ public final class AgeDimensionService {
AgeBiomeSelection selection = age.plan() != null && age.plan().biomeSelection() != null
? age.plan().biomeSelection()
: AgeBiomeSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse(OrderedWriting.EMPTY),
age.traits(), profile, age.seed(), server.registryAccess());
age.traits(), profile, age.seed(), server.registryAccess(), age.randomPagePolicy());
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
List<Holder<Biome>> biomes = new ArrayList<>();
List<ResourceLocation> fallbacks = AgeBiomeSelection.eligible(server.registryAccess(), profile);
@@ -158,12 +158,19 @@ public final class AgeFeatureSelection {
/** Page-free Ages roll up to two material features, plus rare Skylands, once at creation. */
public static List<Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries) {
return randomChoices(ageSeed, profile, registries, RandomPagePolicy.UNRESTRICTED);
}
public static List<Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries,
RandomPagePolicy policy) {
Random random = new Random(ageSeed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
List<LegacyRankChoice.Option<ResourceLocation>> features = new ArrayList<>();
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null)
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null
&& policy.allows(featurePage(id)))
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null)
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null
&& policy.allows(featurePage(id)))
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
List<Choice> choices = new ArrayList<>();
while (choices.size() < count && !features.isEmpty()) {
@@ -172,23 +179,30 @@ public final class AgeFeatureSelection {
var category = solidFeature(feature)
? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY;
List<ResourceLocation> materials = BlockSymbolPages.availableBlocks(category).stream()
.filter(id -> policy.allows(BlockSymbolPages.symbolId(id)))
.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())))));
}
if (profile.terrain() == AgeRuntimeProfile.Terrain.NOISE
&& policy.allows(featurePage(id("skylands")))
&& Math.floorMod(mix(ageSeed ^ RANDOM_SKYLANDS_SALT), 8) == 0)
choices.add(new Choice(id("skylands"), Optional.empty()));
if (error(CRYSTAL_FORMATIONS, profile, registries) == null && random.nextInt(10) == 0)
if (error(CRYSTAL_FORMATIONS, profile, registries) == null
&& policy.allows(featurePage(CRYSTAL_FORMATIONS)) && random.nextInt(10) == 0)
choices.add(new Choice(CRYSTAL_FORMATIONS, Optional.empty()));
if (randomStarFissure(ageSeed))
if (policy.allows(featurePage(STAR_FISSURES)) && randomStarFissure(ageSeed))
choices.add(new Choice(STAR_FISSURES, Optional.empty()));
AgeBiomeSelection biomes = AgeBiomeSelection.compile(WritingGrammar.parse(OrderedWriting.EMPTY),
AgeTraits.select(ageSeed), profile, ageSeed, registries);
choices.addAll(AgeStructureSelection.randomChoices(ageSeed, profile, biomes, registries));
AgeTraits.select(ageSeed, policy), profile, ageSeed, registries, policy);
choices.addAll(AgeStructureSelection.randomChoices(ageSeed, profile, biomes, registries, policy));
return List.copyOf(choices);
}
private static ResourceLocation featurePage(ResourceLocation id) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", "feature/" + id.getPath());
}
static int randomMaterialRank(ResourceLocation feature) {
if (feature.equals(id("surface_lakes")) || feature.equals(id("deep_lakes"))) return 1;
if (feature.equals(id("lava_lakes"))) return 2; // New separate fluid choice.
@@ -56,6 +56,11 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
}
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries, long ageSeed) {
return compute(plan, traits, registries, ageSeed, RandomPagePolicy.UNRESTRICTED);
}
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries,
long ageSeed, RandomPagePolicy policy) {
Objects.requireNonNull(traits, "traits");
List<Contribution> contributions = new ArrayList<>();
if (plan == null) {
@@ -87,7 +92,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
contributions.add(new Contribution("bright_lighting", -1, BRIGHT_LIGHTING_COST,
"Seeded bright lighting"));
}
AgeSkyProfile sky = plan == null && ageSeed != Long.MIN_VALUE ? AgeSkyProfile.random(ageSeed, traits)
AgeSkyProfile sky = plan == null && ageSeed != Long.MIN_VALUE ? AgeSkyProfile.random(ageSeed, traits, policy)
: plan == null ? new AgeSkyProfile(AgeSkyProfile.Style.INHERITED, -1, List.of())
: AgeSkyProfile.compile(plan, traits);
int seenSuns = 0;
@@ -39,6 +39,7 @@ public final class AgeRegistrySavedData extends SavedData {
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 RANDOM_PAGE_POLICY_TAG = "RandomPagePolicy";
private static final String SEASON_MODE_TAG = "SeasonMode";
private static final String INSTABILITY_TAG = "Instability";
private static final String INSTABILITY_PLAN_TAG = "InstabilityPlan";
@@ -77,9 +78,19 @@ 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,
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed,
AgeRuntimeProfiles.selectForWriting(seed, writing, overworld.registryAccess()), AgeTraits.select(seed),
overworld.registryAccess());
if (plan == null) {
RandomPagePolicy policy = RandomPagePolicy.configured();
ResourceLocation profileId = AgeRuntimeProfiles.select(seed, policy);
AgeRuntimeProfile profile = AgeRuntimeProfiles.resolve(profileId);
AgeTraits traits = AgeTraits.select(seed, policy);
AgeBiomeSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse(OrderedWriting.EMPTY),
traits, profile, seed, overworld.registryAccess(), policy);
AgeSkyProfile.random(seed, traits, policy);
AgeSeasonMode.select(null, seed, ModList.get().isLoaded("sereneseasons"), profile, policy);
}
}
public static ResourceLocation previewGenerationProfile(ServerLevel level, String name) {
@@ -155,7 +166,8 @@ public final class AgeRegistrySavedData extends SavedData {
AgeDefinition changed = new AgeDefinition(previous.id(), previous.name(), seed,
previous.generationProfile(), previous.generationVersion(), previous.createdGameTime(),
previous.dimensionId(), previous.spawn(), previous.traits(), plan, previous.seasonMode(),
previous.randomFeatures(), previous.instability(), previous.instabilityPlan());
previous.randomFeatures(), previous.instability(), previous.instabilityPlan(),
previous.randomPagePolicy());
if (AgeDimensionService.unload(server, previous) == AgeDimensionService.UnloadResult.FAILED)
return false;
ages.put(ageId, changed);
@@ -173,13 +185,19 @@ public final class AgeRegistrySavedData extends SavedData {
String name = normalizedName(reference.name());
ResourceLocation dimensionId = availableDimensionId(name);
long seed = ageSeed(worldSeed, dimensionId);
RandomPagePolicy policy = RandomPagePolicy.configured();
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
AgeTraits traits = AgeTraits.select(seed);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
if (plan == null) {
seedProfile = AgeRuntimeProfiles.select(seed, policy);
traits = AgeTraits.select(seed, policy);
}
AgeTraits effectiveTraits = plan == null
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
: AgeEnvironmentCompiler.apply(plan, traits);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries, seed);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries, seed,
plan == null ? policy : RandomPagePolicy.UNRESTRICTED);
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
AgeDefinition created = new AgeDefinition(
reference.id(),
@@ -193,11 +211,13 @@ public final class AgeRegistrySavedData extends SavedData {
effectiveTraits,
plan,
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"),
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile()),
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile(),
plan == null ? policy : RandomPagePolicy.UNRESTRICTED),
plan == null ? AgeFeatureSelection.randomChoices(seed,
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
AgeRuntimeProfiles.resolve(seedProfile), registries, policy) : List.of(),
instability,
instabilityPlan
instabilityPlan,
plan == null ? policy : RandomPagePolicy.UNRESTRICTED
);
ages.put(created.id(), created);
setDirty();
@@ -264,7 +284,10 @@ public final class AgeRegistrySavedData extends SavedData {
? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null,
readRandomFeatures(entry),
instability,
instabilityPlan
instabilityPlan,
entry.contains(RANDOM_PAGE_POLICY_TAG, CompoundTag.TAG_COMPOUND)
? RandomPagePolicy.load(entry.getCompound(RANDOM_PAGE_POLICY_TAG))
: RandomPagePolicy.UNRESTRICTED
));
}
return data;
@@ -309,6 +332,7 @@ public final class AgeRegistrySavedData extends SavedData {
entry.put(RANDOM_FEATURES_TAG, featureTags);
}
if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name());
if (age.plan() == null) entry.put(RANDOM_PAGE_POLICY_TAG, age.randomPagePolicy().save());
entry.put(INSTABILITY_TAG, age.instability().save());
entry.put(INSTABILITY_PLAN_TAG, age.instabilityPlan().save());
serializedAges.add(entry);
@@ -57,20 +57,39 @@ public final class AgeRuntimeProfiles {
/** Selects from all valid loaded profiles in stable resource-ID order. */
public static ResourceLocation select(long seed) {
return select(seed, RandomPagePolicy.UNRESTRICTED);
}
public static ResourceLocation select(long seed, RandomPagePolicy policy) {
if (PROFILES.isEmpty()) {
return AgeGenerationProfiles.select(seed);
List<LegacyRankChoice.Option<ResourceLocation>> profiles = policy.required(
AgeGenerationProfiles.all().stream().sorted()
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id)))
.toList(), option -> terrainPage(option.value()),
new LegacyRankChoice.Option<>(AgeGenerationProfiles.STANDARD, 1));
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
}
List<LegacyRankChoice.Option<ResourceLocation>> profiles = PROFILES.keySet().stream().sorted()
List<LegacyRankChoice.Option<ResourceLocation>> available = PROFILES.keySet().stream().sorted()
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id))).toList();
LegacyRankChoice.Option<ResourceLocation> fallback = available.stream()
.filter(option -> option.value().equals(AgeGenerationProfiles.STANDARD))
.findFirst().orElse(available.getFirst());
List<LegacyRankChoice.Option<ResourceLocation>> profiles = policy.required(available,
option -> terrainPage(option.value()), fallback);
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
}
private static ResourceLocation terrainPage(ResourceLocation profile) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", "terrain/" + profile.getPath());
}
/** 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.activeSymbols().stream().anyMatch(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)) return selected;
boolean authoredTerrain = parsed.activeSymbols().stream().anyMatch(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN);
ResourceLocation selected = select(seed);
if (authoredTerrain) return selected;
List<ResourceLocation> features = parsed.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList();
@@ -24,15 +24,23 @@ public enum AgeSeasonMode {
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
AgeRuntimeProfile profile) {
return select(plan, ageSeed, sereneInstalled, profile, RandomPagePolicy.UNRESTRICTED);
}
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
AgeRuntimeProfile profile, RandomPagePolicy policy) {
if (profile != null && AgeBiomeSelection.isCave(profile)) return NO_SEASONS;
if (hasAuthoredPage(plan)) return from(plan);
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), policy.required(List.of(
new LegacyRankChoice.Option<>(SEASONS, 1),
new LegacyRankChoice.Option<>(NO_SEASONS, 2),
new LegacyRankChoice.Option<>(ETERNAL_WINTER, 3),
new LegacyRankChoice.Option<>(ETERNAL_SUMMER, 3),
new LegacyRankChoice.Option<>(ETERNAL_FALL, 3),
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3))) : null;
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3)),
option -> net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("rivencraft",
"season/" + option.value().name().toLowerCase(Locale.ROOT)),
new LegacyRankChoice.Option<>(NO_SEASONS, 2))) : null;
}
/** Existing Ages without a stored choice retain their old cycling behavior. */
@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Random;
import java.util.Set;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.minecraft.resources.ResourceLocation;
/** The ordered, deterministic sky specification for an Age. No client data is trusted here. */
public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
@@ -23,16 +24,25 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
public static AgeSkyProfile forAge(AgeDefinition age) {
if (age.plan() != null) return compile(age.plan(), age.traits());
if (age.generationVersion() >= RANDOM_SKY_GENERATION_VERSION)
return random(age.seed(), age.traits());
return random(age.seed(), age.traits(), age.randomPagePolicy());
return new AgeSkyProfile(Style.INHERITED, -1, List.of());
}
public static AgeSkyProfile random(long seed, AgeTraits traits) {
return random(seed, traits, RandomPagePolicy.UNRESTRICTED);
}
public static AgeSkyProfile random(long seed, AgeTraits traits, RandomPagePolicy policy) {
Random random = new Random(seed ^ 0x534B5950524F464CL);
Style style = Style.values()[random.nextInt(Style.values().length)];
List<Style> styles = policy.required(java.util.Arrays.stream(Style.values())
.filter(style -> style != Style.INHERITED || !policy.restricts("sky")).toList(),
AgeSkyProfile::skyPage, Style.INHERITED);
Style style = styles.get(random.nextInt(styles.size()));
int color = randomColor(random);
int suns = random.nextInt(4);
int moons = random.nextInt(4);
int suns = policy.allows(ResourceLocation.fromNamespaceAndPath("rivencraft", "celestial/sun"))
? random.nextInt(4) : 0;
int moons = policy.allows(ResourceLocation.fromNamespaceAndPath("rivencraft", "celestial/moon"))
? random.nextInt(4) : 0;
List<Body> bodies = new ArrayList<>();
for (int i = 0; i < suns; i++)
bodies.add(new Body(Kind.SUN, randomColor(random),
@@ -45,6 +55,11 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
return new AgeSkyProfile(style, color, bodies);
}
private static ResourceLocation skyPage(Style style) {
return ResourceLocation.fromNamespaceAndPath("rivencraft",
"sky/" + (style == Style.INHERITED ? "inherited" : style.name().toLowerCase(java.util.Locale.ROOT)));
}
public static AgeSkyProfile compile(AgeGenerationPlan plan, AgeTraits traits) {
if (plan == null) return new AgeSkyProfile(Style.INHERITED, -1, List.of());
Style style = Style.INHERITED;
@@ -157,7 +157,14 @@ public final class AgeStructureSelection {
public static List<AgeFeatureSelection.Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile,
AgeBiomeSelection biomes, RegistryAccess registries) {
return randomChoices(ageSeed, profile, biomes, registries, RandomPagePolicy.UNRESTRICTED);
}
public static List<AgeFeatureSelection.Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile,
AgeBiomeSelection biomes, RegistryAccess registries,
RandomPagePolicy policy) {
List<LegacyRankChoice.Option<ResourceLocation>> eligible = new ArrayList<>(FEATURES.stream()
.filter(id -> policy.allows(ResourceLocation.fromNamespaceAndPath("rivencraft", "feature/" + id.getPath())))
.filter(id -> error(id, profile, biomes, registries) == null)
.filter(id -> dungeons(id) || biomeCompatible(id, biomes, registries))
.map(id -> new LegacyRankChoice.Option<>(id, randomRank(id))).toList());
@@ -2,6 +2,8 @@ package net.dnorberg.rivencraft.book;
import java.util.Random;
import java.util.List;
import java.util.Locale;
import net.minecraft.resources.ResourceLocation;
/** Immutable random-Age traits selected once from the Age seed. */
public record AgeTraits(
@@ -42,8 +44,12 @@ public record AgeTraits(
}
public static AgeTraits select(long seed) {
return select(seed, RandomPagePolicy.UNRESTRICTED);
}
public static AgeTraits select(long seed, RandomPagePolicy policy) {
Random random = new Random(seed ^ 0x524956454E435241L);
BiomeDistribution biomeDistribution = LegacyRankChoice.choose(random, List.of(
BiomeDistribution biomeDistribution = LegacyRankChoice.choose(random, policy.required(List.of(
new LegacyRankChoice.Option<>(BiomeDistribution.NATIVE, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.SINGLE, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.MEDIUM, 1),
@@ -52,12 +58,16 @@ public record AgeTraits(
new LegacyRankChoice.Option<>(BiomeDistribution.TINY, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.SMALL, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.LARGE, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.HUGE, 2)));
Lighting lighting = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(BiomeDistribution.HUGE, 2)),
option -> page("biome", option.value()),
new LegacyRankChoice.Option<>(BiomeDistribution.NATIVE, 1)));
Lighting lighting = LegacyRankChoice.choose(random, policy.required(List.of(
new LegacyRankChoice.Option<>(Lighting.NORMAL, 1),
new LegacyRankChoice.Option<>(Lighting.DARK, 2),
new LegacyRankChoice.Option<>(Lighting.BRIGHT, 2)));
Weather weather = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(Lighting.BRIGHT, 2)),
option -> page("lighting", option.value()),
new LegacyRankChoice.Option<>(Lighting.NORMAL, 1)));
Weather weather = LegacyRankChoice.choose(random, policy.required(List.of(
new LegacyRankChoice.Option<>(Weather.NORMAL, 1),
new LegacyRankChoice.Option<>(Weather.FAST, 2),
new LegacyRankChoice.Option<>(Weather.SLOW, 2),
@@ -66,7 +76,9 @@ public record AgeTraits(
new LegacyRankChoice.Option<>(Weather.CLOUDY, 2),
new LegacyRankChoice.Option<>(Weather.RAIN, 2),
new LegacyRankChoice.Option<>(Weather.SNOW, 2),
new LegacyRankChoice.Option<>(Weather.STORM, 2)));
new LegacyRankChoice.Option<>(Weather.STORM, 2)),
option -> page("weather", option.value()),
new LegacyRankChoice.Option<>(Weather.NORMAL, 1)));
// Legacy's PERIOD_BASIC grammar gives ModZero rank 2 and its other
// three length modifiers rank 1, producing an effective 1-in-7 weight.
boolean stopped = random.nextInt(7) == 0;
@@ -81,6 +93,11 @@ public record AgeTraits(
);
}
private static ResourceLocation page(String category, Enum<?> value) {
return ResourceLocation.fromNamespaceAndPath("rivencraft",
category + "/" + value.name().toLowerCase(Locale.ROOT));
}
public boolean hasStoppedCelestialCycle() {
return celestialPeriod == STOPPED_CELESTIAL_PERIOD;
}
@@ -0,0 +1,91 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.StringTag;
import net.minecraft.resources.ResourceLocation;
/** A snapshot of Page eligibility, frozen with each random Age at binding time. */
public record RandomPagePolicy(Set<ResourceLocation> allowlist, Set<ResourceLocation> blocklist) {
public static final RandomPagePolicy UNRESTRICTED = new RandomPagePolicy(Set.of(), Set.of());
public RandomPagePolicy {
allowlist = Set.copyOf(allowlist);
blocklist = Set.copyOf(blocklist);
}
public static RandomPagePolicy configured() {
return new RandomPagePolicy(parse(RivencraftConfig.randomPageAllowlist()),
parse(RivencraftConfig.randomPageBlocklist()));
}
private static Set<ResourceLocation> parse(List<? extends String> ids) {
return ids.stream().map(RandomPagePolicy::parsePageId).collect(Collectors.toUnmodifiableSet());
}
/** Server config IDs may omit the built-in Rivencraft namespace. */
public static ResourceLocation parsePageId(String value) {
ResourceLocation id = ResourceLocation.tryParse(value.contains(":") ? value : "rivencraft:" + value);
if (id == null) throw new IllegalArgumentException("Invalid Page ID " + value);
return id;
}
public boolean allows(ResourceLocation id) {
if (blocklist.contains(id)) return false;
String category = category(id);
return !restricts(category) || allowlist.contains(id);
}
public boolean restricts(String category) {
return allowlist.stream().anyMatch(entry -> category(entry).equals(category));
}
public <T> List<T> eligible(List<T> candidates, java.util.function.Function<T, ResourceLocation> pageId) {
return candidates.stream().filter(candidate -> allows(pageId.apply(candidate))).toList();
}
public <T> List<T> required(List<T> candidates, java.util.function.Function<T, ResourceLocation> pageId,
T fallback) {
List<T> eligible = eligible(candidates, pageId);
return eligible.isEmpty() ? List.of(fallback) : eligible;
}
private static String category(ResourceLocation id) {
String path = id.getPath();
if (path.startsWith("biome/") && java.util.Set.of("native", "single", "tiled", "grid",
"tiny", "small", "medium", "large", "huge").contains(path.substring("biome/".length())))
return "biome_distribution";
return path.substring(0, path.indexOf('/') < 0 ? path.length() : path.indexOf('/'));
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.put("Allowlist", strings(allowlist));
tag.put("Blocklist", strings(blocklist));
return tag;
}
public static RandomPagePolicy load(CompoundTag tag) {
return new RandomPagePolicy(read(tag.getList("Allowlist", StringTag.TAG_STRING)),
read(tag.getList("Blocklist", StringTag.TAG_STRING)));
}
private static ListTag strings(Set<ResourceLocation> ids) {
ListTag tags = new ListTag();
ids.stream().sorted().forEach(id -> tags.add(StringTag.valueOf(id.toString())));
return tags;
}
private static Set<ResourceLocation> read(ListTag tags) {
java.util.Set<ResourceLocation> ids = new java.util.HashSet<>();
for (int index = 0; index < tags.size(); index++) {
ResourceLocation id = ResourceLocation.tryParse(tags.getString(index));
if (id != null) ids.add(id);
}
return Set.copyOf(ids);
}
}
@@ -23,6 +23,8 @@ public final class RivencraftConfig {
private static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
private static final ModConfigSpec.ConfigValue<List<? extends String>> RANDOM_PAGE_ALLOWLIST;
private static final ModConfigSpec.ConfigValue<List<? extends String>> RANDOM_PAGE_BLOCKLIST;
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
private static final ModConfigSpec.BooleanValue INSTABILITY_ENABLED;
private static final ModConfigSpec.IntValue INSTABILITY_DIFFICULTY;
@@ -89,6 +91,21 @@ public final class RivencraftConfig {
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline link-panel page.")
.defineInRange("link_panel_ink_cost", 2, 1, 16);
builder.pop();
builder.push("random_pages");
RANDOM_PAGE_ALLOWLIST = builder.comment(
"Optional Page paths allowed in new random Ages and newly generated library loot.",
"The allowlist restricts only categories it mentions; unmentioned categories remain unrestricted.",
"Example: [\"sky/static\"] restricts random sky styles to Static Sky.",
"Full namespaced IDs are also accepted for compatibility.")
.defineList("allowlist", List.<String>of(), () -> "sky/static",
RivencraftConfig::validPageId);
RANDOM_PAGE_BLOCKLIST = builder.comment(
"Page paths excluded from new random Ages and newly generated library loot.",
"The blocklist wins over the allowlist. Example: [\"sky/static\"].",
"Existing Ages and Pages deliberately remain usable in hand-authored books.")
.defineList("blocklist", List.<String>of(), () -> "sky/static",
RivencraftConfig::validPageId);
builder.pop();
builder.push("serene_seasons");
AUTO_WHITELIST_SERENE_AGES = builder.comment(
"Automatically add Rivencraft Ages to Serene Seasons' dimension whitelist when loaded.",
@@ -233,6 +250,14 @@ public final class RivencraftConfig {
return LINK_PANEL_INK_COST.get();
}
public static List<? extends String> randomPageAllowlist() { return RANDOM_PAGE_ALLOWLIST.get(); }
public static List<? extends String> randomPageBlocklist() { return RANDOM_PAGE_BLOCKLIST.get(); }
private static boolean validPageId(Object value) {
return value instanceof String id && !id.isBlank()
&& ResourceLocation.tryParse(id.contains(":") ? id : "rivencraft:" + id) != null;
}
public static boolean autoWhitelistSereneAges() {
return AUTO_WHITELIST_SERENE_AGES.get();
}
@@ -212,8 +212,10 @@ public final class LinkingGameTests {
"A page-free book must select a supported legacy-inspired terrain profile");
helper.assertValueEqual(first.generationVersion(), AgeDefinition.CURRENT_GENERATION_VERSION,
"New Ages must record the current generation algorithm version");
helper.assertValueEqual(first.traits(), AgeTraits.select(first.seed()),
"A page-free book must persist the deterministic traits selected from its seed");
helper.assertValueEqual(first.traits(), net.dnorberg.rivencraft.book.AgeEnvironmentCompiler.forTerrain(
AgeTraits.select(first.seed()),
net.dnorberg.rivencraft.book.AgeRuntimeProfiles.resolve(first.generationProfile())),
"A page-free book must persist deterministic traits, with Cave sky/weather normalization");
helper.assertTrue(first.traits().hasStoppedCelestialCycle()
|| first.traits().celestialPeriod() >= AgeTraits.MIN_CELESTIAL_PERIOD
&& first.traits().celestialPeriod() <= AgeTraits.MAX_CELESTIAL_PERIOD,
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.book.RandomPagePolicy;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
@@ -336,9 +337,17 @@ public final class MystcraftLibraryFeature {
static List<ResourceLocation> symbolsForLoot(List<ResourceLocation> ageSymbols,
RivencraftConfig.LibraryLootMode mode) {
return symbolsForLoot(ageSymbols, mode, RandomPagePolicy.configured());
}
static List<ResourceLocation> symbolsForLoot(List<ResourceLocation> ageSymbols,
RivencraftConfig.LibraryLootMode mode,
RandomPagePolicy policy) {
return mode == RivencraftConfig.LibraryLootMode.AGE_ONLY ? ageSymbols.stream()
.filter(policy::allows)
.filter(id -> SymbolCatalog.isKnown(new SymbolReference(id))).distinct().toList()
: SymbolCatalog.symbols().entrySet().stream()
.filter(entry -> policy.allows(entry.getKey()))
.filter(entry -> entry.getKey().getNamespace().equals("rivencraft")
&& entry.getValue().semantics().isPresent()
&& !entry.getKey().getPath().startsWith("block/")
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.world;
import java.util.List;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.book.RandomPagePolicy;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
@@ -107,6 +108,85 @@ public final class MystcraftLibraryGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void pageListsFilterRandomChoicesAndLibraryLoot(GameTestHelper helper) {
ResourceLocation staticSky = ResourceLocation.parse("rivencraft:sky/static");
ResourceLocation netherSky = ResourceLocation.parse("rivencraft:sky/nether");
ResourceLocation rocks = ResourceLocation.parse("rivencraft:feature/rocks");
helper.assertValueEqual(RandomPagePolicy.parsePageId("sky/static"), staticSky,
"A shorthand Page path must resolve to Rivencraft's namespace");
helper.assertValueEqual(RandomPagePolicy.parsePageId("rivencraft:sky/static"), staticSky,
"An existing fully qualified Page ID must still work");
RandomPagePolicy policy = new RandomPagePolicy(java.util.Set.of(staticSky, netherSky),
java.util.Set.of(netherSky, rocks));
helper.assertTrue(policy.allows(staticSky), "Allowlisted sky Page must remain available");
helper.assertTrue(!policy.allows(netherSky), "Blocklist must override allowlist");
helper.assertTrue(!policy.allows(rocks), "Blocked feature Page must be excluded");
helper.assertTrue(policy.allows(ResourceLocation.parse("rivencraft:terrain/standard")),
"Unmentioned categories must remain unrestricted");
RandomPagePolicy biomeController = new RandomPagePolicy(
java.util.Set.of(ResourceLocation.parse("rivencraft:biome/tiny")), java.util.Set.of());
helper.assertTrue(biomeController.allows(ResourceLocation.parse("rivencraft:biome/plains")),
"A biome-controller allowlist must not exclude actual biome Pages");
helper.assertTrue(!biomeController.allows(ResourceLocation.parse("rivencraft:biome/huge")),
"A biome-controller allowlist must constrain other distributions");
helper.assertValueEqual(RandomPagePolicy.load(policy.save()), policy,
"Random Age Page policy must survive save and reload");
helper.assertValueEqual(MystcraftLibraryFeature.symbolsForLoot(
List.of(staticSky, netherSky, rocks), RivencraftConfig.LibraryLootMode.AGE_ONLY, policy),
List.of(staticSky), "Age-only library loot must exclude blocked Pages");
for (long seed = 0; seed < 100; seed++) {
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeSkyProfile.random(seed,
net.dnorberg.rivencraft.book.AgeTraits.select(seed), policy).style(),
net.dnorberg.rivencraft.book.AgeSkyProfile.Style.STATIC,
"Sky allowlist must constrain new random Ages");
}
RandomPagePolicy singleTerrain = new RandomPagePolicy(
java.util.Set.of(ResourceLocation.parse("rivencraft:terrain/standard"),
ResourceLocation.parse("rivencraft:lighting/normal")), java.util.Set.of());
for (long seed = 0; seed < 100; seed++) {
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeRuntimeProfiles.select(seed, singleTerrain),
ResourceLocation.parse("rivencraft:standard"),
"Random terrain must honor a category allowlist");
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeTraits.select(seed, singleTerrain).lighting(),
net.dnorberg.rivencraft.book.AgeTraits.Lighting.NORMAL,
"Random lighting must honor a category allowlist");
}
RandomPagePolicy noRequiredPages = new RandomPagePolicy(java.util.Set.of(
ResourceLocation.parse("rivencraft:terrain/not_present"),
ResourceLocation.parse("rivencraft:lighting/not_present"),
ResourceLocation.parse("rivencraft:weather/not_present"),
ResourceLocation.parse("rivencraft:sky/not_present"),
ResourceLocation.parse("rivencraft:season/not_present")), java.util.Set.of());
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeRuntimeProfiles.select(42L, noRequiredPages),
ResourceLocation.parse("rivencraft:standard"), "Empty terrain family must use Standard");
var fallbackTraits = net.dnorberg.rivencraft.book.AgeTraits.select(42L, noRequiredPages);
helper.assertValueEqual(fallbackTraits.lighting(),
net.dnorberg.rivencraft.book.AgeTraits.Lighting.NORMAL,
"Empty lighting family must use Normal");
helper.assertValueEqual(fallbackTraits.weather(),
net.dnorberg.rivencraft.book.AgeTraits.Weather.NORMAL,
"Empty weather family must use Normal");
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeSkyProfile.random(42L,
fallbackTraits, noRequiredPages).style(),
net.dnorberg.rivencraft.book.AgeSkyProfile.Style.INHERITED,
"Empty sky family must use inherited sky");
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeSeasonMode.select(null, 42L,
true, null, noRequiredPages),
net.dnorberg.rivencraft.book.AgeSeasonMode.NO_SEASONS,
"Empty season family must use No Seasons");
var blockedControllers = java.util.Arrays.stream(
net.dnorberg.rivencraft.book.AgeTraits.BiomeDistribution.values())
.map(value -> ResourceLocation.parse("rivencraft:biome/"
+ value.name().toLowerCase(java.util.Locale.ROOT)))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeTraits.select(42L,
new RandomPagePolicy(java.util.Set.of(), blockedControllers)).biomeDistribution(),
net.dnorberg.rivencraft.book.AgeTraits.BiomeDistribution.NATIVE,
"Empty biome-distribution family must use Native");
helper.succeed();
}
@GameTest(template = "empty")
public static void testSpacingIsSessionOnlyAndKeepsLegacySites(GameTestHelper helper) {
long seed = 881L;