Compare commits

..
1 Commits
Author SHA1 Message Date
dnorberg 109acc449a Add data-pack Age runtime profiles (#9)
Build / build (push) Canceled after 0s
2026-09-23 02:15:39 -04:00
18 changed files with 345 additions and 43 deletions

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# Age runtime profiles
Rivencraft loads Age runtime profiles from server data packs at
`data/<namespace>/rivencraft/age_profiles/<name>.json`. The resulting profile ID
is `<namespace>:<name>`. Profiles select registered world-generation resources;
they do not contain an Age's seed, name, spawn, or rolled traits.
## Schema version 1
```json
{
"schema_version": 1,
"terrain": "noise",
"noise_settings": "minecraft:overworld",
"biome_preset": "minecraft:overworld",
"dimension_type": "minecraft:overworld"
}
```
`terrain` is `noise`, `flat`, or `void`. Noise profiles require
`noise_settings` and `biome_preset` references. Every profile requires a
`dimension_type` reference. Omitted resource references use the Overworld
entry. Rivencraft validates all referenced entries against the loaded dynamic
registries during data-pack reload. An invalid profile is rejected.
The built-in profile IDs are `rivencraft:standard`, `amplified`, `island`,
`cave`, `flat`, and `void`. Their JSON files are ordinary built-in data-pack
resources and can be overridden by a higher-priority pack. The example under
`docs/example-age-profile-pack` can be copied into a world's `datapacks`
directory. Valid custom profiles join the stable, sorted pool used when a new
Age rolls its base terrain; the selected ID is then persisted with that Age.
## Persistence and reload behavior
An Age permanently stores its selected profile ID, generation version, seed,
and rolled traits. `/reload` replaces the profile definitions but does not
reroll those immutable values. An unloaded Age resolves its stored profile
again when it loads, so intentional profile overrides affect its runtime
resources and newly generated chunks.
A profile removed after an Age was created resolves to `rivencraft:standard`.
The legacy `rivencraft:random_vanilla` alias also resolves to `standard`.
Rivencraft logs this fallback. Invalid built-in overrides do not replace the
required built-in definitions; a reload fails if a required built-in profile
is absent after resource resolution.
Only schema version 1 is accepted. A future schema change must add an explicit
migration path before incrementing the version. The saved Age generation
version remains independent of this data-pack schema and is never silently
rewritten.
+2 -1
View File
@@ -47,7 +47,8 @@ Age references use UUIDs rather than dimension keys. The server-owned Age
registry maps each stable identity to its immutable definition and runtime
dimension, keeping saved books valid if an Age is migrated or regenerated.
Operational lifecycle and unload behavior are documented in
[AGES.md](AGES.md).
[AGES.md](AGES.md). The server data-pack schema for runtime generation is
documented in [AGE_PROFILES.md](AGE_PROFILES.md).
The core mod must remain usable without Create, Ponder, or Immersive Portals.
The built-in legacy animation is the dependency-free destination preview.
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"terrain": "noise",
"noise_settings": "minecraft:amplified",
"biome_preset": "minecraft:overworld",
"dimension_type": "minecraft:overworld"
}
@@ -0,0 +1,6 @@
{
"pack": {
"pack_format": 48,
"description": "Example Rivencraft Age runtime profile"
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.2.27.0
mod_version=0.2.28.0
immersive_portals_version=6.0.7
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
@@ -17,6 +17,7 @@ import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeWeatherService;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
@@ -48,6 +49,7 @@ public final class Rivencraft {
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register);
LOGGER.info("Initializing Rivencraft");
}
}
@@ -5,7 +5,6 @@ import dev.galacticraft.dynamicdimensions.api.PlayerRemover;
import dev.galacticraft.dynamicdimensions.api.event.DimensionRemovedCallback;
import dev.galacticraft.dynamicdimensions.api.event.DynamicDimensionLoadCallback;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.List;
import java.util.Comparator;
import java.util.Collections;
@@ -23,7 +22,6 @@ import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.util.valueproviders.UniformInt;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.biome.Biomes;
@@ -32,7 +30,6 @@ import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.CheckerboardColumnBiomeSource;
import net.minecraft.world.level.biome.FixedBiomeSource;
import net.minecraft.world.level.biome.MultiNoiseBiomeSource;
import net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterLists;
import net.dnorberg.rivencraft.world.ScaledBiomeSource;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
@@ -65,7 +62,7 @@ public final class AgeDimensionService {
ServerLevel level = loader.loadDynamicDimension(
age.dimensionId(),
createGenerator(server, age),
createDimensionType(age)
createDimensionType(server, age)
);
if (level != null) {
applyCelestialClock(level, age);
@@ -107,7 +104,7 @@ public final class AgeDimensionService {
ServerLevel loaded = DynamicDimensionRegistry.from(server).loadDynamicDimension(
definition.dimensionId(),
createGenerator(server, definition),
createDimensionType(definition)
createDimensionType(server, definition)
);
if (loaded == null) {
Rivencraft.LOGGER.error("Failed to load Age {} as {}", definition.id(), definition.dimensionId());
@@ -264,15 +261,15 @@ public final class AgeDimensionService {
}
private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) {
ResourceLocation profile = age.generationProfile();
if (profile.equals(AgeGenerationProfiles.FLAT)) {
AgeRuntimeProfile profile = AgeRuntimeProfiles.resolve(age.generationProfile());
if (profile.terrain() == AgeRuntimeProfile.Terrain.FLAT) {
return new FlatLevelSource(FlatLevelGeneratorSettings.getDefault(
server.registryAccess().lookupOrThrow(Registries.BIOME),
server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
));
}
if (profile.equals(AgeGenerationProfiles.VOID)) {
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings(
Optional.empty(),
server.registryAccess().lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.THE_VOID),
@@ -285,22 +282,11 @@ public final class AgeDimensionService {
var biomeParameters = server.registryAccess()
.lookupOrThrow(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST);
var noiseSettings = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
ResourceKey<NoiseGeneratorSettings> settingsKey;
if (profile.equals(AgeGenerationProfiles.CAVE)) {
settingsKey = NoiseGeneratorSettings.CAVES;
} else if (profile.equals(AgeGenerationProfiles.ISLAND)) {
settingsKey = NoiseGeneratorSettings.FLOATING_ISLANDS;
} else if (profile.equals(AgeGenerationProfiles.AMPLIFIED)) {
settingsKey = NoiseGeneratorSettings.AMPLIFIED;
} else {
settingsKey = NoiseGeneratorSettings.OVERWORLD;
}
// Cave uses Minecraft's enclosed cave density with stone, water, and
// Overworld-like surfaces while retaining the Age's biome source.
// Island/Skylands uses uniform floating-island density without the
// End's central dragon island. Both use the Overworld environment.
// random_vanilla is retained as a migration alias for existing Ages.
BiomeSource biomeSource = createBiomeSource(server, age, biomeParameters);
ResourceKey<NoiseGeneratorSettings> settingsKey = ResourceKey.create(
Registries.NOISE_SETTINGS, profile.noiseSettings());
ResourceKey<net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList> biomePreset =
ResourceKey.create(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST, profile.biomePreset());
BiomeSource biomeSource = createBiomeSource(server, age, biomeParameters, biomePreset);
NoiseGeneratorSettings baseSettings = noiseSettings.getOrThrow(settingsKey).value();
return new NoiseBasedChunkGenerator(biomeSource, Holder.direct(withBiomeMaterialRules(baseSettings)));
}
@@ -347,7 +333,8 @@ public final class AgeDimensionService {
private static BiomeSource createBiomeSource(
MinecraftServer server,
AgeDefinition age,
net.minecraft.core.HolderGetter<net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList> biomeParameters
net.minecraft.core.HolderGetter<net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList> biomeParameters,
ResourceKey<net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList> biomePreset
) {
List<net.minecraft.core.Holder.Reference<Biome>> biomes = server.registryAccess()
.lookupOrThrow(Registries.BIOME)
@@ -394,29 +381,32 @@ public final class AgeDimensionService {
);
}
return MultiNoiseBiomeSource.createFromPreset(
biomeParameters.getOrThrow(MultiNoiseBiomeSourceParameterLists.OVERWORLD));
biomeParameters.getOrThrow(biomePreset));
}
private static DimensionType createDimensionType(AgeDefinition age) {
private static DimensionType createDimensionType(MinecraftServer server, AgeDefinition age) {
AgeRuntimeProfile profile = AgeRuntimeProfiles.resolve(age.generationProfile());
DimensionType base = server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE)
.getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, profile.dimensionType())).value();
AgeTraits.Lighting lighting = age.traits().lighting();
boolean hasSkyLight = lighting != AgeTraits.Lighting.DARK;
float ambientLight = lighting == AgeTraits.Lighting.BRIGHT ? 0.25F : 0.0F;
return new DimensionType(
OptionalLong.empty(),
base.fixedTime(),
hasSkyLight,
false,
false,
true,
1.0D,
false,
false,
-64,
384,
384,
BlockTags.INFINIBURN_OVERWORLD,
BuiltinDimensionTypes.OVERWORLD_EFFECTS,
base.hasCeiling(),
base.ultraWarm(),
base.natural(),
base.coordinateScale(),
base.bedWorks(),
base.respawnAnchorWorks(),
base.minY(),
base.height(),
base.logicalHeight(),
base.infiniburn(),
base.effectsLocation(),
ambientLight,
new DimensionType.MonsterSettings(false, true, UniformInt.of(0, 7), 0)
base.monsterSettings()
);
}
@@ -33,6 +33,14 @@ public final class AgeGenerationProfiles {
return RANDOM_PROFILES;
}
/** Maps removed legacy aliases to a stable built-in profile. */
public static ResourceLocation migrationFallback(ResourceLocation profile) {
if (profile.equals(AgeDefinition.RANDOM_VANILLA_PROFILE)) {
return STANDARD;
}
return isSupported(profile) ? profile : STANDARD;
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
@@ -91,7 +91,7 @@ public final class AgeRegistrySavedData extends SavedData {
reference.id(),
name,
seed,
AgeGenerationProfiles.select(seed),
AgeRuntimeProfiles.select(seed),
AgeDefinition.CURRENT_GENERATION_VERSION,
createdGameTime,
dimensionId,
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.book;
import net.minecraft.resources.ResourceLocation;
/** Data-pack-defined resources used to construct an Age runtime dimension. */
public record AgeRuntimeProfile(
int schemaVersion,
Terrain terrain,
ResourceLocation noiseSettings,
ResourceLocation biomePreset,
ResourceLocation dimensionType
) {
public static final int CURRENT_SCHEMA_VERSION = 1;
public enum Terrain {
NOISE,
FLAT,
VOID
}
}
@@ -0,0 +1,140 @@
package net.dnorberg.rivencraft.book;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener;
import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterList;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.neoforged.neoforge.event.AddReloadListenerEvent;
/** Loads {@code data/<namespace>/rivencraft/age_profiles/*.json}. */
public final class AgeRuntimeProfiles {
private static final Gson GSON = new GsonBuilder().create();
private static final String DIRECTORY = "rivencraft/age_profiles";
private static final Map<ResourceLocation, AgeRuntimeProfile> PROFILES = new ConcurrentHashMap<>();
private AgeRuntimeProfiles() {
}
public static void register(AddReloadListenerEvent event) {
event.addListener(new Loader(event.getRegistryAccess()));
}
public static AgeRuntimeProfile resolve(ResourceLocation id) {
AgeRuntimeProfile profile = PROFILES.get(id);
if (profile != null) {
return profile;
}
ResourceLocation fallback = AgeGenerationProfiles.migrationFallback(id);
profile = PROFILES.get(fallback);
if (profile == null) {
throw new IllegalStateException("Missing built-in Age runtime profile " + fallback);
}
Rivencraft.LOGGER.warn("Age runtime profile {} is unavailable; using deterministic fallback {}", id, fallback);
return profile;
}
public static Map<ResourceLocation, AgeRuntimeProfile> profiles() {
return Map.copyOf(PROFILES);
}
/** Selects from all valid loaded profiles in stable resource-ID order. */
public static ResourceLocation select(long seed) {
if (PROFILES.isEmpty()) {
return AgeGenerationProfiles.select(seed);
}
java.util.List<ResourceLocation> ids = PROFILES.keySet().stream().sorted().toList();
return ids.get(Math.floorMod(Long.hashCode(seed), ids.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;
if (schemaVersion != AgeRuntimeProfile.CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("unsupported schema_version " + schemaVersion);
}
AgeRuntimeProfile.Terrain terrain = AgeRuntimeProfile.Terrain.valueOf(
requiredString(object, "terrain").toUpperCase(java.util.Locale.ROOT));
ResourceLocation noise = location(object, "noise_settings", "minecraft:overworld");
ResourceLocation biome = location(object, "biome_preset", "minecraft:overworld");
ResourceLocation dimension = location(object, "dimension_type", "minecraft:overworld");
if (terrain == AgeRuntimeProfile.Terrain.NOISE) {
requireRegistryEntry(registries, Registries.NOISE_SETTINGS, noise, id, "noise_settings");
requireRegistryEntry(registries, Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST,
biome, id, "biome_preset");
}
requireRegistryEntry(registries, Registries.DIMENSION_TYPE, dimension, id, "dimension_type");
return new AgeRuntimeProfile(schemaVersion, terrain, noise, biome, dimension);
}
private static String requiredString(JsonObject object, String name) {
if (!object.has(name)) {
throw new IllegalArgumentException("missing " + name);
}
return object.get(name).getAsString();
}
private static ResourceLocation location(JsonObject object, String name, String fallback) {
ResourceLocation id = ResourceLocation.tryParse(object.has(name) ? object.get(name).getAsString() : fallback);
if (id == null) {
throw new IllegalArgumentException("invalid " + name);
}
return id;
}
private static <T> void requireRegistryEntry(
RegistryAccess registries,
ResourceKey<? extends net.minecraft.core.Registry<T>> registry,
ResourceLocation entry,
ResourceLocation profile,
String field
) {
if (registries.lookupOrThrow(registry).get(ResourceKey.create(registry, entry)).isEmpty()) {
throw new IllegalArgumentException(profile + " references missing " + field + " " + entry);
}
}
private static final class Loader extends SimpleJsonResourceReloadListener {
private final RegistryAccess registries;
private Loader(RegistryAccess registries) {
super(GSON, DIRECTORY);
this.registries = registries;
}
@Override
protected void apply(Map<ResourceLocation, JsonElement> resources,
ResourceManager resourceManager,
ProfilerFiller profiler) {
Map<ResourceLocation, AgeRuntimeProfile> loaded = new java.util.HashMap<>();
resources.forEach((id, json) -> {
try {
loaded.put(id, parse(id, json, registries));
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Ignoring invalid Age runtime profile {}: {}", id, exception.getMessage());
}
});
for (ResourceLocation required : AgeGenerationProfiles.all()) {
if (!loaded.containsKey(required)) {
throw new IllegalStateException("Required built-in Age runtime profile is missing: " + required);
}
}
PROFILES.clear();
PROFILES.putAll(loaded);
Rivencraft.LOGGER.info("Loaded {} Age runtime profiles", loaded.size());
}
}
}
@@ -6,6 +6,8 @@ import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.AgeRuntimeProfile;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.book.AgeVisitSavedData;
@@ -38,6 +40,44 @@ public final class LinkingGameTests {
private LinkingGameTests() {
}
@GameTest(template = "empty")
public static void dataPackAgeProfilesResolveRegisteredResources(GameTestHelper helper) {
var registries = helper.getLevel().registryAccess();
var profiles = AgeRuntimeProfiles.profiles();
helper.assertTrue(profiles.keySet().containsAll(AgeGenerationProfiles.all()),
"Every built-in Age runtime profile must survive data-pack validation");
for (var entry : profiles.entrySet()) {
AgeRuntimeProfile profile = entry.getValue();
helper.assertValueEqual(profile.schemaVersion(), AgeRuntimeProfile.CURRENT_SCHEMA_VERSION,
entry.getKey() + " must use the current runtime-profile schema");
helper.assertTrue(registries.lookupOrThrow(net.minecraft.core.registries.Registries.DIMENSION_TYPE)
.get(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.DIMENSION_TYPE,
profile.dimensionType())).isPresent(),
entry.getKey() + " must reference a registered dimension type");
if (profile.terrain() == AgeRuntimeProfile.Terrain.NOISE) {
helper.assertTrue(registries.lookupOrThrow(net.minecraft.core.registries.Registries.NOISE_SETTINGS)
.get(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.NOISE_SETTINGS,
profile.noiseSettings())).isPresent(),
entry.getKey() + " must reference registered noise settings");
helper.assertTrue(registries.lookupOrThrow(
net.minecraft.core.registries.Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST)
.get(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST,
profile.biomePreset())).isPresent(),
entry.getKey() + " must reference a registered biome preset");
}
}
var missing = net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("example", "removed_profile");
helper.assertValueEqual(
AgeRuntimeProfiles.resolve(missing),
AgeRuntimeProfiles.resolve(AgeGenerationProfiles.STANDARD),
"A removed custom profile must use the documented deterministic fallback"
);
helper.succeed();
}
@GameTest(template = "empty")
public static void linkTargetSurvivesItemSerialization(GameTestHelper helper) {
LinkTarget expected = new LinkTarget(
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"terrain": "noise",
"noise_settings": "minecraft:amplified",
"biome_preset": "minecraft:overworld",
"dimension_type": "minecraft:overworld"
}
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"terrain": "noise",
"noise_settings": "minecraft:caves",
"biome_preset": "minecraft:overworld",
"dimension_type": "minecraft:overworld"
}
@@ -0,0 +1,5 @@
{
"schema_version": 1,
"terrain": "flat",
"dimension_type": "minecraft:overworld"
}
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"terrain": "noise",
"noise_settings": "minecraft:floating_islands",
"biome_preset": "minecraft:overworld",
"dimension_type": "minecraft:overworld"
}
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"terrain": "noise",
"noise_settings": "minecraft:overworld",
"biome_preset": "minecraft:overworld",
"dimension_type": "minecraft:overworld"
}
@@ -0,0 +1,5 @@
{
"schema_version": 1,
"terrain": "void",
"dimension_type": "minecraft:overworld"
}