Compare commits

...
3 Commits
Author SHA1 Message Date
dnorberg 954dd7097b Complete Milestone 3 documentation (#12)
Build / build (push) Canceled after 0s
2026-09-23 05:51:59 -04:00
dnorberg 1937a8c71d Add Age migration regression coverage (#11)
Build / build (push) Canceled after 0s
2026-09-23 05:41:38 -04:00
dnorberg 24b60706b0 Document and test preview compatibility (#10)
Build / build (push) Canceled after 0s
2026-09-23 02:44:02 -04:00
10 changed files with 470 additions and 39 deletions

No files matched your search

+11 -4
View File
@@ -4,10 +4,10 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
1.21.1 on NeoForge. 1.21.1 on NeoForge.
The bootstrap and books/linking milestones are complete. The project currently The bootstrap, books/linking, and Ages milestones are complete. The project
provides functional Linking Books and randomly seeded, persistent vanilla-like currently provides functional Linking Books and randomly seeded, persistent
Ages created from page-free Descriptive Books. Symbols, instability, and the vanilla-like Ages created from page-free Descriptive Books. Symbols,
full writing mechanics have not been ported yet. instability, and the full writing mechanics have not been ported yet.
## Linking Book ## Linking Book
@@ -60,6 +60,10 @@ according to the configured Return Link policy.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`. server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
Age creation, persistence, lifecycle commands, configuration, backups, and
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
Data-pack runtime profiles are documented in
[docs/AGE_PROFILES.md](docs/AGE_PROFILES.md).
## Requirements ## Requirements
@@ -77,6 +81,9 @@ view. Without either integration, Rivencraft uses its built-in animated linking
panel. Clients can set `book_preview.mode` in panel. Clients can set `book_preview.mode` in
`rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. `rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
Unavailable optional renderers safely fall back to `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 ## Build and run
+71 -1
View File
@@ -1,9 +1,35 @@
# Age administration # Age creation and administration
## Creation
A page-free Descriptive Book creates an Age when the player assigns a name and
presses **Create**. Rivencraft immediately assigns a UUID, derives a unique
Minecraft-safe dimension identifier from the requested name, rolls a seed and
traits, and persists that definition before asking Dynamic Dimensions to create
the runtime world. Names that normalize to the same identifier receive stable
suffixes such as `_2` and `_3`.
The selected terrain profile, biome distribution, lighting, weather, celestial
period, celestial angle, and phase are deterministic and become part of the
saved definition. A five-by-five cobblestone platform with clear headroom is
prepared at a safe arrival point. The completed book and every copy carrying
the same UUID resolve to that definition.
Creating an Age can take several ticks. The book cannot be reopened while its
creation request is active. If creation is interrupted after the definition is
saved, using the book again resumes from the same immutable definition instead
of rolling a replacement.
## Persistence
Rivencraft owns each Age definition for the lifetime of a world. A definition Rivencraft owns each Age definition for the lifetime of a world. A definition
contains the Age UUID, canonical name, seed, generation version, runtime contains the Age UUID, canonical name, seed, generation version, runtime
dimension identifier, spawn, and deterministic traits. Definitions are stored dimension identifier, spawn, and deterministic traits. Definitions are stored
in the Overworld saved data and are never rerolled during ordinary loading. in the Overworld saved data and are never rerolled during ordinary loading.
Runtime terrain and entity data remain in the normal world dimension storage
managed by Minecraft and Dynamic Dimensions. Back up the complete world folder
while the server is stopped; copying only a dimension folder does not preserve
the authoritative Age registry.
## Lifecycle ## Lifecycle
@@ -37,3 +63,47 @@ The commands require permission level 2.
Opening a Descriptive Book for an unloaded Age loads it again from its saved Opening a Descriptive Book for an unloaded Age loads it again from its saved
definition. definition.
## Configuration
Rivencraft writes gameplay settings to the world/server configuration managed
by NeoForge. `descriptive_books.return_link_mode` accepts:
- `ON_CREATION` (default): create one Return Link the first time a player
enters each Age.
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
- `DISABLED`: never create Return Links automatically.
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
only presentation and cannot change server-side Age generation or travel
validation. See [PREVIEW_COMPATIBILITY.md](PREVIEW_COMPATIBILITY.md) for the
selection matrix and integration limits.
Runtime generation profiles are server data-pack resources. Their schema,
override rules, reload behavior, and deterministic fallback are documented in
[AGE_PROFILES.md](AGE_PROFILES.md).
## Administrator recovery
Use this sequence when a book cannot load an existing Age:
1. Stop the server and make a copy of the complete world folder.
2. Start the server and run `/rivencraft age list`. Confirm that the expected
canonical name and dimension identifier remain registered.
3. If the Age is listed as unloaded, open its Descriptive Book to reload it.
This keeps the UUID, seed, profile, traits, and existing world folder.
4. If a data-pack profile is missing or invalid, restore or repair that pack,
run `/reload`, then unload and reopen the Age. A removed custom profile uses
the deterministic `rivencraft:standard` fallback and logs the substitution.
5. If the definition is missing, restore the complete world backup. Moving a
dimension folder into another world cannot reconstruct its UUID or books.
6. If the definition exists but its dimension data is damaged, restore the
matching dimension data and authoritative world saved data from the same
backup generation. Do not combine them from different backups.
There is intentionally no delete command in Milestone 3. Do not manually
delete an Age definition or rename its dimension folder: books may exist in
inventories, containers, Book Stands, or unloaded chunks, and would be
orphaned. `/rivencraft age unload` is the supported reversible maintenance
operation.
+4 -2
View File
@@ -31,7 +31,7 @@ acceptance issue and all required implementation issues are complete.
**Complete.** **Complete.**
3. **Ages** — persistent generated dimensions, deterministic traits, 3. **Ages** — persistent generated dimensions, deterministic traits,
lifecycle management, data-pack integration, and optional destination lifecycle management, data-pack integration, and optional destination
renderer compatibility. **Active.** See renderer compatibility. **Complete.** See
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8). [Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly. 4. **Writing system** — pages, symbols, notebooks, desks, and book assembly.
5. **Age grammar** — translate symbol combinations into modern biome, noise, 5. **Age grammar** — translate symbol combinations into modern biome, noise,
@@ -53,7 +53,9 @@ documented in [AGE_PROFILES.md](AGE_PROFILES.md).
The core mod must remain usable without Create, Ponder, or Immersive Portals. The core mod must remain usable without Create, Ponder, or Immersive Portals.
The built-in legacy animation is the dependency-free destination preview. The built-in legacy animation is the dependency-free destination preview.
Optional preview modes may use Create/Ponder snapshots or Immersive Portals 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 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 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.
+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. # 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 mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.2.28.0 mod_version=0.3.0.0
immersive_portals_version=6.0.7 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. # 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. # This should match the base package used for the mod sources.
@@ -0,0 +1,132 @@
package net.dnorberg.rivencraft.book;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Saved-data migration and round-trip acceptance coverage for persistent Ages. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgePersistenceGameTests {
private AgePersistenceGameTests() {
}
@GameTest(template = "empty")
public static void everyTraitCombinationRoundTripsWithoutRerolling(GameTestHelper helper) {
ListTag entries = new ListTag();
AgeTraits.BiomeDistribution[] distributions = AgeTraits.BiomeDistribution.values();
AgeTraits.Lighting[] lightingValues = AgeTraits.Lighting.values();
AgeTraits.Weather[] weatherValues = AgeTraits.Weather.values();
for (int index = 0; index < distributions.length; index++) {
UUID id = new UUID(0x524956454E000000L + index, 0x4147450000000000L + index);
AgeTraits traits = new AgeTraits(
distributions[index],
lightingValues[index % lightingValues.length],
weatherValues[index % weatherValues.length],
index == 0 ? AgeTraits.STOPPED_CELESTIAL_PERIOD : 0.25D + index * 0.15D,
index * 37.5F,
index * 0.1F
);
entries.add(entry(
id,
"Persistent " + distributions[index],
9000L + index,
AgeGenerationProfiles.all().get(index % AgeGenerationProfiles.all().size()),
3 + index,
1200L + index,
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/persistent_" + index),
new BlockPos(index, 70 + index, -index),
traits
));
}
CompoundTag root = new CompoundTag();
root.put("Ages", entries);
AgeRegistrySavedData first = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
CompoundTag serialized = first.save(new CompoundTag(), helper.getLevel().registryAccess());
AgeRegistrySavedData restored = AgeRegistrySavedData.load(serialized, helper.getLevel().registryAccess());
for (int index = 0; index < distributions.length; index++) {
UUID id = new UUID(0x524956454E000000L + index, 0x4147450000000000L + index);
helper.assertValueEqual(restored.find(id).orElseThrow(), first.find(id).orElseThrow(),
distributions[index] + " and its weather, lighting, celestial, and identity data must round-trip");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void legacyDefinitionReceivesStableDefaults(GameTestHelper helper) {
UUID id = UUID.fromString("4d797374-6372-6166-7400-000000000001");
CompoundTag legacy = new CompoundTag();
legacy.putUUID("Id", id);
legacy.putString("Name", " Legacy Age ");
legacy.putLong("Seed", 8675309L);
legacy.putString("GenerationProfile", AgeDefinition.RANDOM_VANILLA_PROFILE.toString());
legacy.putLong("CreatedGameTime", 42L);
ListTag entries = new ListTag();
entries.add(legacy);
CompoundTag root = new CompoundTag();
root.put("Ages", entries);
AgeRegistrySavedData migrated = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
AgeDefinition definition = migrated.find(id).orElseThrow();
helper.assertValueEqual(definition.id(), id, "Migration must retain the Age UUID");
helper.assertValueEqual(definition.name(), "Legacy Age", "Migration must normalize and retain the Age name");
helper.assertValueEqual(definition.seed(), 8675309L, "Migration must retain the Age seed");
helper.assertValueEqual(definition.generationProfile(), AgeDefinition.RANDOM_VANILLA_PROFILE,
"Migration must retain the legacy generation profile for deterministic runtime fallback");
helper.assertValueEqual(definition.generationVersion(), 1,
"Definitions predating generation versions must receive version 1");
helper.assertValueEqual(definition.dimensionId(), AgeDefinition.dimensionId(id),
"Definitions predating named dimensions must retain the UUID-derived dimension");
helper.assertValueEqual(definition.traits(), AgeTraits.LEGACY_DEFAULTS,
"Definitions predating traits must receive stable legacy defaults");
CompoundTag serialized = migrated.save(new CompoundTag(), helper.getLevel().registryAccess());
AgeDefinition reloaded = AgeRegistrySavedData.load(serialized, helper.getLevel().registryAccess())
.find(id).orElseThrow();
helper.assertValueEqual(reloaded, definition, "A migrated definition must not reroll on its next restart");
helper.succeed();
}
private static CompoundTag entry(
UUID id,
String name,
long seed,
ResourceLocation profile,
int version,
long createdTime,
ResourceLocation dimension,
BlockPos spawn,
AgeTraits traits
) {
CompoundTag entry = new CompoundTag();
entry.putUUID("Id", id);
entry.putString("Name", name);
entry.putLong("Seed", seed);
entry.putString("GenerationProfile", profile.toString());
entry.putInt("GenerationVersion", version);
entry.putLong("CreatedGameTime", createdTime);
entry.putString("Dimension", dimension.toString());
entry.putInt("SpawnX", spawn.getX());
entry.putInt("SpawnY", spawn.getY());
entry.putInt("SpawnZ", spawn.getZ());
CompoundTag serializedTraits = new CompoundTag();
serializedTraits.putString("BiomeDistribution", traits.biomeDistribution().name());
serializedTraits.putString("Lighting", traits.lighting().name());
serializedTraits.putString("Weather", traits.weather().name());
serializedTraits.putDouble("CelestialPeriod", traits.celestialPeriod());
serializedTraits.putFloat("CelestialAngle", traits.celestialAngle());
serializedTraits.putFloat("CelestialPhase", traits.celestialPhase());
entry.put("Traits", serializedTraits);
return entry;
}
}
@@ -103,7 +103,7 @@ public final class AgeRegistrySavedData extends SavedData {
return created; return created;
} }
private static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) { static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeRegistrySavedData data = new AgeRegistrySavedData(); AgeRegistrySavedData data = new AgeRegistrySavedData();
ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND); ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND);
for (int index = 0; index < serializedAges.size(); index++) { for (int index = 0; index < serializedAges.size(); index++) {
@@ -111,12 +111,18 @@ public final class AgeRegistrySavedData extends SavedData {
if (!entry.hasUUID(ID_TAG)) { if (!entry.hasUUID(ID_TAG)) {
continue; continue;
} }
ResourceLocation profile = ResourceLocation.tryParse(entry.getString(PROFILE_TAG)); ResourceLocation profile = entry.contains(PROFILE_TAG, CompoundTag.TAG_STRING)
&& !entry.getString(PROFILE_TAG).isBlank()
? ResourceLocation.tryParse(entry.getString(PROFILE_TAG))
: null;
if (profile == null) { if (profile == null) {
profile = AgeDefinition.RANDOM_VANILLA_PROFILE; profile = AgeDefinition.RANDOM_VANILLA_PROFILE;
} }
UUID id = entry.getUUID(ID_TAG); UUID id = entry.getUUID(ID_TAG);
ResourceLocation dimensionId = ResourceLocation.tryParse(entry.getString(DIMENSION_TAG)); ResourceLocation dimensionId = entry.contains(DIMENSION_TAG, CompoundTag.TAG_STRING)
&& !entry.getString(DIMENSION_TAG).isBlank()
? ResourceLocation.tryParse(entry.getString(DIMENSION_TAG))
: null;
if (dimensionId == null) { if (dimensionId == null) {
dimensionId = AgeDefinition.dimensionId(id); dimensionId = AgeDefinition.dimensionId(id);
} }
@@ -1,44 +1,22 @@
package net.dnorberg.rivencraft.client.preview; package net.dnorberg.rivencraft.client.preview;
import net.dnorberg.rivencraft.config.RivencraftClientConfig; import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.compat.PreviewBackendSelector;
import net.neoforged.fml.ModList; import net.neoforged.fml.ModList;
/** Selects the configured destination preview while keeping optional integrations optional. */ /** Selects the configured destination preview while keeping optional integrations optional. */
public final class DestinationPreviewRenderers { public final class DestinationPreviewRenderers {
private enum Backend {
IMMERSIVE_PORTALS,
PONDER,
LEGACY
}
private DestinationPreviewRenderers() { private DestinationPreviewRenderers() {
} }
public static DestinationPreviewRenderer create() { public static DestinationPreviewRenderer create() {
boolean ponderAvailable = ModList.get().isLoaded("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"); boolean immersivePortalsAvailable = ModList.get().isLoaded("immersive_portals_core");
return switch (selectBackend( return switch (PreviewBackendSelector.select(
RivencraftClientConfig.previewMode(), immersivePortalsAvailable, ponderAvailable)) { RivencraftClientConfig.previewMode(), immersivePortalsAvailable, ponderAvailable)) {
case IMMERSIVE_PORTALS -> new ImmersivePortalsDestinationPreviewRenderer(); case IMMERSIVE_PORTALS -> new ImmersivePortalsDestinationPreviewRenderer();
case PONDER -> new PonderDestinationPreviewRenderer(); case PONDER -> new PonderDestinationPreviewRenderer();
case LEGACY -> new LegacyDestinationPreviewRenderer(); 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;
};
}
}
@@ -12,6 +12,8 @@ import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkingService; import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.book.AgeVisitSavedData; import net.dnorberg.rivencraft.book.AgeVisitSavedData;
import net.dnorberg.rivencraft.book.AgeTraits; 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.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
@@ -40,6 +42,53 @@ public final class LinkingGameTests {
private 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") @GameTest(template = "empty")
public static void dataPackAgeProfilesResolveRegisteredResources(GameTestHelper helper) { public static void dataPackAgeProfilesResolveRegisteredResources(GameTestHelper helper) {
var registries = helper.getLevel().registryAccess(); var registries = helper.getLevel().registryAccess();
@@ -192,6 +241,78 @@ public final class LinkingGameTests {
}); });
} }
@GameTest(template = "empty", timeoutTicks = 400)
public static void everyTerrainProfileProvidesSafeArrivalAndReturnTravel(GameTestHelper helper) {
ServerPlayer player = helper.makeMockServerPlayerInLevel();
BlockPos returnPosition = player.blockPosition();
helper.getLevel().setBlock(returnPosition.below(), Blocks.STONE.defaultBlockState(), 2);
helper.getLevel().setBlock(returnPosition, Blocks.AIR.defaultBlockState(), 2);
helper.getLevel().setBlock(returnPosition.above(), Blocks.AIR.defaultBlockState(), 2);
LinkTarget returnTarget = new LinkTarget(
GlobalPos.of(helper.getLevel().dimension(), returnPosition),
player.getYRot(),
player.getXRot()
);
long worldSeed = helper.getLevel().getServer().overworld().getSeed();
runTerrainProfileTravel(helper, player, returnTarget, AgeGenerationProfiles.all(), 0, worldSeed);
}
private static void runTerrainProfileTravel(
GameTestHelper helper,
ServerPlayer player,
LinkTarget returnTarget,
java.util.List<net.minecraft.resources.ResourceLocation> profiles,
int index,
long worldSeed
) {
if (index >= profiles.size()) {
helper.succeed();
return;
}
var profile = profiles.get(index);
AgeReference reference = referenceSelectingProfile(profile, worldSeed);
LinkTarget destination = AgeDimensionService.resolve(player, reference).orElse(null);
helper.assertTrue(destination != null, profile + " must create a destination");
if (destination == null) {
return;
}
helper.runAfterDelay(1, () -> {
AgeDefinition definition = AgeRegistrySavedData.get(helper.getLevel()).find(reference.id()).orElseThrow();
helper.assertValueEqual(definition.generationProfile(), profile,
"The acceptance fixture must select " + profile);
var ageLevel = player.getServer().getLevel(destination.location().dimension());
helper.assertTrue(ageLevel != null, profile + " must create a loaded runtime dimension");
BlockPos spawn = destination.location().pos();
helper.assertTrue(ageLevel != null && ageLevel.getBlockState(spawn.below()).is(Blocks.COBBLESTONE),
profile + " must create a solid arrival platform");
helper.assertTrue(ageLevel != null && ageLevel.getBlockState(spawn).isAir()
&& ageLevel.getBlockState(spawn.above()).isAir(),
profile + " must create clear arrival headroom");
helper.assertValueEqual(LinkingService.travelWithFooting(player, destination),
LinkingService.LinkResult.SUCCESS, profile + " must accept safe inbound travel");
helper.assertValueEqual(LinkingService.travel(player, returnTarget),
LinkingService.LinkResult.SUCCESS, profile + " must permit return travel");
helper.runAfterDelay(1, () -> runTerrainProfileTravel(
helper, player, returnTarget, profiles, index + 1, worldSeed));
});
}
private static AgeReference referenceSelectingProfile(
net.minecraft.resources.ResourceLocation profile,
long worldSeed
) {
UUID id = UUID.randomUUID();
for (int candidate = 0; candidate < 100_000; candidate++) {
String name = "Profile Acceptance " + profile.getPath() + " " + id + " " + candidate;
var dimension = AgeDefinition.namedDimensionId(name);
long seed = worldSeed + dimension.hashCode();
if (AgeRuntimeProfiles.select(seed).equals(profile)) {
return new AgeReference(id, name);
}
}
throw new IllegalStateException("Could not select profile " + profile);
}
@GameTest(template = "empty", timeoutTicks = 100) @GameTest(template = "empty", timeoutTicks = 100)
public static void unloadedAgeReloadsWithTheSameDefinition(GameTestHelper helper) { public static void unloadedAgeReloadsWithTheSameDefinition(GameTestHelper helper) {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
@@ -480,7 +601,9 @@ public final class LinkingGameTests {
} }
helper.assertTrue(level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream().allMatch(holder -> holder.is(biome)), helper.assertTrue(level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream().allMatch(holder -> holder.is(biome)),
"The material test Age must select " + biome.location()); "The material test Age must select " + biome.location());
// Sample away from the generated arrival platform around the Age spawn. // Sample away from the generated arrival platform. Vanilla surface rules
// can place a shallow local patch such as gravel over the biome's base block.
boolean foundExpectedSurface = false;
int sampleX = 64; int sampleX = 64;
int sampleZ = 64; int sampleZ = 64;
level.getChunk(sampleX >> 4, sampleZ >> 4); level.getChunk(sampleX >> 4, sampleZ >> 4);
@@ -489,10 +612,15 @@ public final class LinkingGameTests {
sampleX, sampleX,
sampleZ sampleZ
) - 1; ) - 1;
var surfaceState = level.getBlockState(new BlockPos(sampleX, surfaceY, sampleZ)); for (int depth = 0; depth <= 8; depth++) {
helper.assertTrue(surfaceState.is(expectedBlock), if (level.getBlockState(new BlockPos(sampleX, surfaceY - depth, sampleZ)).is(expectedBlock)) {
foundExpectedSurface = true;
break;
}
}
helper.assertTrue(foundExpectedSurface,
biome.location() + " terrain must expose " + expectedBlock.getDescriptionId() biome.location() + " terrain must expose " + expectedBlock.getDescriptionId()
+ " at the surface, found " + surfaceState); + " across the sampled surface");
} }
@GameTest(template = "empty") @GameTest(template = "empty")