Compare commits

...
2 Commits
Author SHA1 Message Date
dnorberg 24b60706b0 Document and test preview compatibility (#10)
Build / build (push) Canceled after 0s
2026-09-23 02:44:02 -04:00
dnorberg 109acc449a Add data-pack Age runtime profiles (#9)
Build / build (push) Canceled after 0s
2026-09-23 02:15:39 -04:00
22 changed files with 510 additions and 68 deletions

No files matched your search

+3
View File
@@ -77,6 +77,9 @@ view. Without either integration, Rivencraft uses its built-in animated linking
panel. Clients can set `book_preview.mode` in
`rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
Unavailable optional renderers safely fall back to `LEGACY`.
The complete backend matrix, supported combinations, cleanup behavior, and
known upstream limits are documented in
[docs/PREVIEW_COMPATIBILITY.md](docs/PREVIEW_COMPATIBILITY.md).
## Build and run
+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.
+5 -2
View File
@@ -47,12 +47,15 @@ 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.
Optional preview modes may use Create/Ponder snapshots or Immersive Portals
live rendering when their dependencies are installed.
live rendering when their dependencies are installed. The tested selection
matrix and integration boundaries are documented in
[PREVIEW_COMPATIBILITY.md](PREVIEW_COMPATIBILITY.md).
The writing system and Age grammar will build on the existing page-free Age
path. Page-free books remain a useful deterministic fallback and test fixture
+78
View File
@@ -0,0 +1,78 @@
# Destination preview compatibility
Rivencraft has three destination preview backends. The selected backend is a
client presentation detail; the server validates travel and Age generation is
independent of every preview integration.
## Selection matrix
Set `book_preview.mode` in `rivencraft-client.toml`.
| Configured mode | Immersive Portals installed | Create/Ponder installed | Selected backend |
| --- | --- | --- | --- |
| `AUTO` | yes | either | Immersive Portals |
| `AUTO` | no | yes | Ponder snapshot |
| `AUTO` | no | no | built-in legacy animation |
| `IMMERSIVE_PORTALS` | yes | either | Immersive Portals |
| `IMMERSIVE_PORTALS` | no | either | built-in legacy animation |
| `PONDER` | either | yes | Ponder snapshot |
| `PONDER` | either | no | built-in legacy animation |
| `LEGACY` | either | either | built-in legacy animation |
Explicit optional modes fall back directly to `LEGACY`. They do not silently
select the other optional integration. GameTests exercise every row and every
dependency-presence permutation.
## Book behavior
Held Linking and Descriptive Books render the selected backend in the preview
panel. Immersive Portals additionally exposes **Explore**: the player remains
at the opening position, mouse look controls the remote camera, and
right-click returns to the standard book view.
Books on a Book Stand use the same preview panel, but Explore is disabled so
the player cannot move away and leave a remote preview session owned by the
stand screen. Closing either screen releases its server chunk loader. Linking,
disconnecting, unloading the destination Age, and stopping the server also
release affected loaders.
The Immersive Portals backend force-loads the initial 3 by 3 destination area
before registering its wider preview chunk loader. The Ponder backend clears
the previous snapshot before applying replacement block data. The live backend
clears its framebuffer on each render and restores the Minecraft viewport
afterward. These rules prevent stale snapshot frames and book UI elements from
being captured in the preview.
## Supported combinations and upstream limits
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.251, Create
6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY`
backend run without Create or Immersive Portals; the headless GameTest server
uses that dependency-free path.
Immersive Portals 6.0.7 is incompatible with Sodium 0.8.13 for Minecraft
1.21.1: its Sodium compatibility mixin references the removed
`OcclusionCuller.Visitor` class and crashes while entering a world. Sodium
0.6.13 avoids that specific mixin failure. This compatibility boundary belongs
to Immersive Portals and Sodium, before Rivencraft's preview code runs.
Immersive Portals reports that some Create contraptions can be invisible when
the two mods are installed together. Rivencraft does not replace either mod's
world or contraption renderer, so this remains an upstream rendering limit.
The `PONDER` or `LEGACY` mode is the supported fallback when live rendering is
unstable in a particular graphics or mod configuration.
## Release verification
For preview-related releases:
1. Run `./gradlew runGameTestServer` to verify the complete selection matrix,
core Age creation, persistence, and travel without optional renderers.
2. Open held Linking and Descriptive Books once with each available backend.
3. Open both book types from a Book Stand and confirm Explore is disabled.
4. With Immersive Portals, enter and leave Explore, travel between dimensions,
close the screen, and disconnect; confirm destination chunks render and no
preview loader remains active.
5. With Ponder, reopen different destinations and confirm the previous
snapshot does not bleed into the next one.
@@ -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.29.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());
}
}
}
@@ -1,44 +1,22 @@
package net.dnorberg.rivencraft.client.preview;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.compat.PreviewBackendSelector;
import net.neoforged.fml.ModList;
/** Selects the configured destination preview while keeping optional integrations optional. */
public final class DestinationPreviewRenderers {
private enum Backend {
IMMERSIVE_PORTALS,
PONDER,
LEGACY
}
private DestinationPreviewRenderers() {
}
public static DestinationPreviewRenderer create() {
boolean ponderAvailable = ModList.get().isLoaded("create");
// The mode and priority are established before the optional IP renderer
// lands, so selecting it currently follows the documented safe fallback.
boolean immersivePortalsAvailable = ModList.get().isLoaded("immersive_portals_core");
return switch (selectBackend(
return switch (PreviewBackendSelector.select(
RivencraftClientConfig.previewMode(), immersivePortalsAvailable, ponderAvailable)) {
case IMMERSIVE_PORTALS -> new ImmersivePortalsDestinationPreviewRenderer();
case PONDER -> new PonderDestinationPreviewRenderer();
case LEGACY -> new LegacyDestinationPreviewRenderer();
};
}
private static Backend selectBackend(
RivencraftClientConfig.PreviewMode mode,
boolean immersivePortalsAvailable,
boolean ponderAvailable
) {
return switch (mode) {
case AUTO -> immersivePortalsAvailable
? Backend.IMMERSIVE_PORTALS
: ponderAvailable ? Backend.PONDER : Backend.LEGACY;
case IMMERSIVE_PORTALS -> immersivePortalsAvailable ? Backend.IMMERSIVE_PORTALS : Backend.LEGACY;
case PONDER -> ponderAvailable ? Backend.PONDER : Backend.LEGACY;
case LEGACY -> Backend.LEGACY;
};
}
}
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.compat;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
/** Pure selection policy for optional destination preview integrations. */
public final class PreviewBackendSelector {
public enum Backend {
IMMERSIVE_PORTALS,
PONDER,
LEGACY
}
private PreviewBackendSelector() {
}
public static Backend select(
RivencraftClientConfig.PreviewMode mode,
boolean immersivePortalsAvailable,
boolean ponderAvailable
) {
return switch (mode) {
case AUTO -> immersivePortalsAvailable
? Backend.IMMERSIVE_PORTALS
: ponderAvailable ? Backend.PONDER : Backend.LEGACY;
case IMMERSIVE_PORTALS -> immersivePortalsAvailable ? Backend.IMMERSIVE_PORTALS : Backend.LEGACY;
case PONDER -> ponderAvailable ? Backend.PONDER : Backend.LEGACY;
case LEGACY -> Backend.LEGACY;
};
}
}
@@ -6,10 +6,14 @@ 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;
import net.dnorberg.rivencraft.book.AgeTraits;
import net.dnorberg.rivencraft.compat.PreviewBackendSelector;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
@@ -38,6 +42,91 @@ public final class LinkingGameTests {
private LinkingGameTests() {
}
@GameTest(template = "empty")
public static void previewBackendSelectionCoversOptionalDependencyMatrix(GameTestHelper helper) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, true,
PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, true, false,
PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, false, true,
PreviewBackendSelector.Backend.PONDER);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.AUTO, false, false,
PreviewBackendSelector.Backend.LEGACY);
for (boolean ponderAvailable : new boolean[] {false, true}) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.IMMERSIVE_PORTALS, true,
ponderAvailable, PreviewBackendSelector.Backend.IMMERSIVE_PORTALS);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.IMMERSIVE_PORTALS, false,
ponderAvailable, PreviewBackendSelector.Backend.LEGACY);
}
for (boolean immersivePortalsAvailable : new boolean[] {false, true}) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.PONDER,
immersivePortalsAvailable, true, PreviewBackendSelector.Backend.PONDER);
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.PONDER,
immersivePortalsAvailable, false, PreviewBackendSelector.Backend.LEGACY);
}
for (boolean immersivePortalsAvailable : new boolean[] {false, true}) {
for (boolean ponderAvailable : new boolean[] {false, true}) {
assertPreviewBackend(helper, RivencraftClientConfig.PreviewMode.LEGACY,
immersivePortalsAvailable, ponderAvailable, PreviewBackendSelector.Backend.LEGACY);
}
}
helper.succeed();
}
private static void assertPreviewBackend(
GameTestHelper helper,
RivencraftClientConfig.PreviewMode mode,
boolean immersivePortalsAvailable,
boolean ponderAvailable,
PreviewBackendSelector.Backend expected
) {
helper.assertValueEqual(
PreviewBackendSelector.select(mode, immersivePortalsAvailable, ponderAvailable),
expected,
mode + " selection with Immersive Portals=" + immersivePortalsAvailable
+ " and Ponder=" + ponderAvailable
);
}
@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"
}