Compare commits

...
8 Commits
Author SHA1 Message Date
dnorberg 211aa0733b Mute Immersive Portals preview world audio
Build / build (push) Canceled after 0s
2026-09-23 15:20:07 -04:00
dnorberg 8b418ce01c Use legacy unbound linking book texture
Build / build (push) Canceled after 0s
2026-09-23 15:07:55 -04:00
dnorberg 62693c2229 Add blank symbol and link-panel pages (#18)
Build / build (push) Canceled after 0s
2026-09-23 14:49:44 -04:00
dnorberg b8d23ee0c0 Define writing and symbol data model (#17)
Build / build (push) Canceled after 0s
2026-09-23 14:24:40 -04:00
dnorberg f276d412b9 Describe your changes
Build / build (push) Canceled after 0s
2026-09-23 06:30:15 -04:00
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
64 changed files with 1501 additions and 41 deletions

No files matched your search

+18 -4
View File
@@ -4,10 +4,17 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
1.21.1 on NeoForge.
The bootstrap and books/linking milestones are complete. The project currently
provides functional Linking Books and randomly seeded, persistent vanilla-like
Ages created from page-free Descriptive Books. Symbols, instability, and the
full writing mechanics have not been ported yet.
The bootstrap, books/linking, and Ages milestones are complete. Milestone 4 is
building the writing system on a data-pack symbol catalog and persistent
ordered-writing model. The project currently provides functional Linking Books
and randomly seeded, persistent vanilla-like Ages created from page-free
Descriptive Books. Instability and full symbol-to-world grammar interpretation
have not been ported yet.
Milestone 4 now includes stackable blank Pages plus server-created symbol,
unresolved-symbol, and baseline link-panel states. Page visuals follow the
legacy runtime-composition rules; written-page creation will become available
through the Writing Desk work that follows.
## Linking Book
@@ -60,6 +67,10 @@ according to the configured Return Link policy.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
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
@@ -77,6 +88,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

+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
contains the Age UUID, canonical name, seed, generation version, runtime
dimension identifier, spawn, and deterministic traits. Definitions are stored
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
@@ -37,3 +63,47 @@ The commands require permission level 2.
Opening a Descriptive Book for an unloaded Age loads it again from its saved
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.
+16
View File
@@ -0,0 +1,16 @@
# Asset provenance
Rivencraft reuses selected assets from
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) under that
project's GNU LGPL v3 license. Rivencraft itself uses the same license.
| Rivencraft asset | Upstream asset | Upstream revision | Changes |
| --- | --- | --- | --- |
| `assets/rivencraft/textures/item/page.png` | `assets/mystcraft/textures/items/page_background.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
| `assets/rivencraft/textures/item/page_symbol*.png` and `page_unresolved.png` | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Static 1.21.1 renditions of the legacy runtime-composed four-word symbol pages; `normal_time` uses the legacy Full Length poem |
Mystcraft Legacy identifies XCompWiz as its project developer and includes the
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
provides the same license terms.
+11 -2
View File
@@ -31,9 +31,10 @@ acceptance issue and all required implementation issues are complete.
**Complete.**
3. **Ages** — persistent generated dimensions, deterministic traits,
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).
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly.
**In progress.**
5. **Age grammar** — translate symbol combinations into modern biome, noise,
dimension-type, and world-generation configuration. Optional integrations
may add composable biome regions and seasonal behavior.
@@ -53,15 +54,23 @@ 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
after written symbol combinations become available.
Milestone 4 begins with a versioned, data-pack-defined symbol catalog and an
immutable ordered-writing model. See [WRITING_DATA.md](WRITING_DATA.md). These
contracts preserve unknown symbol identifiers and store author intent; they do
not interpret symbols into Age generation, which remains Milestone 5 work.
## Provenance rules
- New code uses the `net.dnorberg.rivencraft` package.
- Reused legacy art is recorded in [ASSET_PROVENANCE.md](ASSET_PROVENANCE.md).
- Adapted Mystcraft code retains its upstream copyright notice and is tracked
in a nearby source comment or commit.
- Generated, binary, and third-party dependency content is not copied from the
+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.
+66
View File
@@ -0,0 +1,66 @@
# Writing and symbol data
Milestone 4 stores what a player wrote without deciding what that writing does
to an Age. Milestone 5 owns grammar validation, symbol interaction, instability,
and translation into biome, noise, dimension-type, and other world-generation
configuration.
## Symbol catalog
The authoritative server catalog loads JSON resources from
`data/<namespace>/rivencraft/symbols/<path>.json`. The resource ID is the
symbol ID. For example, `data/rivencraft/rivencraft/symbols/terrain/standard.json`
defines `rivencraft:terrain/standard`.
Schema version 1 contains display metadata only:
```json
{
"schema_version": 1,
"name": "symbol.rivencraft.terrain.standard",
"description": "symbol.rivencraft.terrain.standard.description",
"category": "rivencraft:terrain"
}
```
`name` and `description` are translation keys, not arbitrary formatted text or
executable behavior. `category` is a namespaced organizational identifier.
Invalid definitions are logged and excluded by the server. Menus and future
page synchronization must obtain resolved display metadata from the server;
clients never make a symbol valid by naming it.
Rivencraft ships a small catalog covering terrain, biomes, lighting, weather,
celestial behavior, and features. It exists to exercise the complete writing
workflow before Milestone 5 implements the full legacy grammar.
## Persistent writing
`SymbolReference` stores only a namespaced symbol ID. `WritingEntry` is either
a symbol reference or a baseline link-panel marker. `OrderedWriting` retains up
to 256 entries in exact player-authored order. All three contracts have both
persistent codecs and bounded network codecs.
Removing a data pack never removes its IDs from saved writing. A missing ID is
resolved as client-safe **Unresolved Symbol** display metadata while retaining
the original namespaced ID for diagnostics and later restoration. This is
deliberately different from treating an unknown symbol as valid grammar.
## Pages
The single stackable `rivencraft:page` item has a bounded, persistent
`rivencraft:page_content` component. Its explicit states are blank, symbol,
unresolved symbol, and baseline link panel. Symbol and unresolved-symbol pages
retain their namespaced symbol reference; unresolved pages can be inspected and
removed but are rejected as usable writing until their data pack returns.
Only server-side writing operations may turn a blank page into a known symbol
page or link panel. The shared `PageItem` creation and validation gates reject
new pages for identifiers absent from the server catalog. Later Writing Desk
and Book Binder packets must call these gates rather than accepting page
contents supplied by a client.
The item model uses server-assigned custom-model data derived from the symbol
identifier. Built-in pages reproduce Mystcraft Legacy's four-word poem
composition from its page background and symbol-component sheet. Data-pack
symbols without a bundled resource-pack model use the legacy question-mark
fallback while keeping their real identifier and display metadata.
+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.28.0
mod_version=0.3.2.2
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.
@@ -18,6 +18,7 @@ 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.dnorberg.rivencraft.writing.SymbolCatalog;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
@@ -50,6 +51,7 @@ public final class Rivencraft {
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register);
NeoForge.EVENT_BUS.addListener(SymbolCatalog::register);
LOGGER.info("Initializing Rivencraft");
}
}
@@ -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;
}
private static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeRegistrySavedData data = new AgeRegistrySavedData();
ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND);
for (int index = 0; index < serializedAges.size(); index++) {
@@ -111,12 +111,18 @@ public final class AgeRegistrySavedData extends SavedData {
if (!entry.hasUUID(ID_TAG)) {
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) {
profile = AgeDefinition.RANDOM_VANILLA_PROFILE;
}
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) {
dimensionId = AgeDefinition.dimensionId(id);
}
@@ -2,6 +2,10 @@ package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.renderer.item.ItemProperties;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
@@ -21,6 +25,11 @@ public final class ClientEvents {
@SubscribeEvent
public static void clientSetup(FMLClientSetupEvent event) {
event.enqueueWork(() -> ItemProperties.register(
ModItems.LINKING_BOOK.get(),
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "bound"),
(stack, level, entity, seed) -> stack.has(ModDataComponents.LINK_TARGET.get()) ? 1.0F : 0.0F
));
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
NeoForge.EVENT_BUS.addListener(ClientBookExploreOverlay::onClientTickPre);
@@ -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;
};
}
}
@@ -42,7 +42,11 @@ public final class ImmersivePortalsDestinationPreviewRenderer implements Destina
@Override
public void accept(LinkPreviewPayload payload) {
if (destination != null) {
ImmersivePortalsPreviewAudioGuard.clear(destination.dimension());
}
destination = payload;
ImmersivePortalsPreviewAudioGuard.mute(payload.dimension());
resetView();
}
@@ -234,5 +238,8 @@ public final class ImmersivePortalsDestinationPreviewRenderer implements Destina
public void close() {
clearMovement();
hasCompletedFrame = false;
if (destination != null) {
ImmersivePortalsPreviewAudioGuard.clear(destination.dimension());
}
}
}
@@ -0,0 +1,31 @@
package net.dnorberg.rivencraft.client.preview;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Prevents an Immersive Portals preview world from contributing sounds to a book screen. */
public final class ImmersivePortalsPreviewAudioGuard {
private static ResourceKey<Level> mutedDimension;
private ImmersivePortalsPreviewAudioGuard() {
}
public static void mute(ResourceKey<Level> dimension) {
mutedDimension = dimension;
}
public static void clear(ResourceKey<Level> dimension) {
if (dimension.equals(mutedDimension)) {
mutedDimension = null;
}
}
public static boolean shouldMute(ClientLevel level) {
Minecraft minecraft = Minecraft.getInstance();
return mutedDimension != null
&& level.dimension().equals(mutedDimension)
&& (minecraft.player == null || level != minecraft.player.level());
}
}
@@ -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.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;
@@ -40,6 +42,53 @@ 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();
@@ -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)
public static void unloadedAgeReloadsWithTheSameDefinition(GameTestHelper helper) {
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)),
"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 sampleZ = 64;
level.getChunk(sampleX >> 4, sampleZ >> 4);
@@ -489,10 +612,15 @@ public final class LinkingGameTests {
sampleX,
sampleZ
) - 1;
var surfaceState = level.getBlockState(new BlockPos(sampleX, surfaceY, sampleZ));
helper.assertTrue(surfaceState.is(expectedBlock),
for (int depth = 0; depth <= 8; depth++) {
if (level.getBlockState(new BlockPos(sampleX, surfaceY - depth, sampleZ)).is(expectedBlock)) {
foundExpectedSurface = true;
break;
}
}
helper.assertTrue(foundExpectedSurface,
biome.location() + " terrain must expose " + expectedBlock.getDescriptionId()
+ " at the surface, found " + surfaceState);
+ " across the sampled surface");
}
@GameTest(template = "empty")
@@ -0,0 +1,124 @@
package net.dnorberg.rivencraft.gametest;
import com.mojang.serialization.JsonOps;
import io.netty.buffer.Unpooled;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Acceptance coverage for Milestone 4's persistent writing contracts. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class WritingDataGameTests {
private WritingDataGameTests() {
}
@GameTest(template = "empty")
public static void orderedWritingPreservesKnownUnknownAndLinkPanelEntries(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference unknown = reference("example:removed_symbol");
OrderedWriting original = new OrderedWriting(List.of(
WritingEntry.symbol(known),
WritingEntry.symbol(unknown),
WritingEntry.linkPanel()
));
var json = OrderedWriting.CODEC.encodeStart(JsonOps.INSTANCE, original).result().orElseThrow();
OrderedWriting persistentCopy = OrderedWriting.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow();
helper.assertValueEqual(persistentCopy, original,
"Persistent writing codec must retain entry order and unknown symbol IDs");
FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer());
OrderedWriting.STREAM_CODEC.encode(buffer, original);
OrderedWriting networkCopy = OrderedWriting.STREAM_CODEC.decode(buffer);
helper.assertValueEqual(networkCopy, original,
"Network writing codec must retain entry order and unknown symbol IDs");
helper.succeed();
}
@GameTest(template = "empty")
public static void serverCatalogResolvesBuiltinsAndKeepsUnknownReferencesReadable(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference unknown = reference("example:removed_symbol");
helper.assertTrue(SymbolCatalog.symbols().size() >= 6,
"The built-in catalog must exercise every Milestone 4 writing workflow");
helper.assertTrue(SymbolCatalog.isKnown(known),
"The built-in Standard Terrain symbol must be server-authoritative");
helper.assertFalse(SymbolCatalog.isKnown(unknown),
"An absent data-pack symbol must remain unresolved");
var unresolved = SymbolCatalog.display(unknown);
helper.assertFalse(unresolved.resolved(),
"Unknown symbol display metadata must be marked unresolved");
helper.assertValueEqual(unresolved.id(), unknown.id(),
"Unresolved display metadata must preserve the original namespaced ID");
helper.succeed();
}
@GameTest(template = "empty")
public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference removed = reference("example:removed_symbol");
List<PageContent> states = List.of(
PageContent.BLANK,
PageContent.symbol(known),
PageContent.unresolved(removed),
PageContent.LINK_PANEL
);
for (PageContent state : states) {
var json = PageContent.CODEC.encodeStart(JsonOps.INSTANCE, state).result().orElseThrow();
PageContent persistentCopy = PageContent.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow();
helper.assertValueEqual(persistentCopy, state, "Page content must persist exactly");
FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer());
PageContent.STREAM_CODEC.encode(buffer, state);
helper.assertValueEqual(PageContent.STREAM_CODEC.decode(buffer), state,
"Page content must network-sync exactly");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference unknown = reference("example:forged_symbol");
ItemStack page = PageItem.createSymbolPage(known);
CompoundTag serialized = (CompoundTag) page.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized);
helper.assertValueEqual(restored.get(ModDataComponents.PAGE_CONTENT.get()), PageContent.symbol(known),
"Symbol page content must survive an ItemStack save/load");
helper.assertValueEqual(
restored.get(ModDataComponents.PAGE_SYMBOL_DISPLAY.get()),
SymbolCatalog.display(known),
"Server-selected display metadata must survive for remote clients"
);
ItemStack recovered = PageItem.createRecoveredPage(unknown);
helper.assertFalse(PageItem.isUsableWriting(recovered),
"An unresolved page must remain recoverable without becoming valid writing");
try {
PageItem.createSymbolPage(unknown);
helper.fail("The server must reject new pages for unknown symbols");
} catch (IllegalArgumentException expected) {
helper.succeed();
}
}
private static SymbolReference reference(String id) {
return new SymbolReference(ResourceLocation.parse(id));
}
}
@@ -0,0 +1,131 @@
package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.component.CustomModelData;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
/** One stackable Page item whose server-owned component identifies its writing state. */
public final class PageItem extends Item {
public PageItem(Properties properties) {
super(properties);
}
public static ItemStack createSymbolPage(SymbolReference reference) {
if (!SymbolCatalog.isKnown(reference)) {
throw new IllegalArgumentException("Cannot create a page for unknown symbol " + reference.id());
}
return create(PageContent.symbol(reference));
}
public static ItemStack createLinkPanelPage() {
return create(PageContent.LINK_PANEL);
}
/** Restores saved unknown references without granting them validity for new writing operations. */
public static ItemStack createRecoveredPage(SymbolReference reference) {
return create(PageContent.unresolved(reference));
}
public static PageContent content(ItemStack stack) {
PageContent content = stack.get(ModDataComponents.PAGE_CONTENT.get());
return content == null ? PageContent.BLANK : content;
}
/** Server-side gate for Desk and Binder inputs; unresolved pages remain removable but not usable. */
public static boolean isUsableWriting(ItemStack stack) {
if (!stack.is(ModItems.PAGE.get())) {
return false;
}
PageContent resolved = content(stack).resolve();
return resolved.kind() == PageContent.Kind.BLANK
|| resolved.kind() == PageContent.Kind.LINK_PANEL
|| resolved.isResolvedSymbol();
}
@Override
public Component getName(ItemStack stack) {
PageContent content = content(stack);
return switch (content.kind()) {
case BLANK -> Component.translatable("item.rivencraft.page.blank");
case LINK_PANEL -> Component.translatable("item.rivencraft.page.link_panel");
case UNRESOLVED_SYMBOL -> Component.translatable("item.rivencraft.page.unresolved");
case SYMBOL -> symbolName(stack, content.symbol().orElseThrow());
};
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context,
List<Component> tooltipComponents, TooltipFlag tooltipFlag) {
PageContent content = content(stack);
if (content.kind() == PageContent.Kind.BLANK) {
tooltipComponents.add(Component.translatable("item.rivencraft.page.blank.description")
.withStyle(ChatFormatting.GRAY));
return;
}
if (content.kind() == PageContent.Kind.LINK_PANEL) {
tooltipComponents.add(Component.translatable("item.rivencraft.page.link_panel.description")
.withStyle(ChatFormatting.GRAY));
return;
}
SymbolReference reference = content.symbol().orElseThrow();
SymbolDisplayMetadata metadata = content.kind() == PageContent.Kind.SYMBOL
? stack.getOrDefault(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference))
: SymbolDisplayMetadata.unresolved(reference);
if (metadata.resolved()) {
tooltipComponents.add(Component.translatable(metadata.descriptionTranslationKey())
.withStyle(ChatFormatting.GRAY));
} else {
tooltipComponents.add(Component.translatable(
"symbol.rivencraft.unresolved.description", reference.unresolvedName())
.withStyle(ChatFormatting.RED));
}
tooltipComponents.add(Component.literal(reference.id().toString()).withStyle(ChatFormatting.DARK_GRAY));
}
private static ItemStack create(PageContent content) {
ItemStack stack = new ItemStack(ModItems.PAGE.get());
stack.set(ModDataComponents.PAGE_CONTENT.get(), content);
stack.set(DataComponents.CUSTOM_MODEL_DATA, new CustomModelData(modelIndex(content)));
if (content.kind() == PageContent.Kind.SYMBOL) {
stack.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
SymbolCatalog.display(content.symbol().orElseThrow()));
}
return stack;
}
private static Component symbolName(ItemStack stack, SymbolReference reference) {
SymbolDisplayMetadata metadata = stack.getOrDefault(
ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference));
return metadata.resolved()
? Component.translatable(metadata.nameTranslationKey())
: Component.translatable("item.rivencraft.page.unresolved");
}
private static int modelIndex(PageContent content) {
return switch (content.kind()) {
case BLANK -> 0;
case LINK_PANEL -> 1;
case UNRESOLVED_SYMBOL -> 2;
case SYMBOL -> switch (content.symbol().orElseThrow().id().toString()) {
case "rivencraft:terrain/standard" -> 100;
case "rivencraft:biome/native" -> 101;
case "rivencraft:lighting/normal" -> 102;
case "rivencraft:weather/normal" -> 103;
case "rivencraft:celestial/normal_time" -> 104;
case "rivencraft:feature/caves" -> 105;
default -> 3;
};
};
}
}
@@ -0,0 +1,59 @@
package net.dnorberg.rivencraft.mixin.client;
import net.dnorberg.rivencraft.client.preview.ImmersivePortalsPreviewAudioGuard;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.Holder;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Mutes only the remote client world displayed by a Rivencraft book preview. */
@Mixin(value = ClientLevel.class, priority = 2000)
public abstract class ClientLevelSoundMixin {
@Inject(
method = "playSound(DDDLnet/minecraft/sounds/SoundEvent;Lnet/minecraft/sounds/SoundSource;FFZJ)V",
at = @At("HEAD"),
cancellable = true
)
private void rivencraft$mutePreviewPositionSound(
double x, double y, double z, SoundEvent sound, SoundSource source,
float volume, float pitch, boolean distanceDelay, long seed, CallbackInfo callback
) {
rivencraft$cancelPreviewSound(callback);
}
@Inject(
method = "playLocalSound(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/sounds/SoundEvent;Lnet/minecraft/sounds/SoundSource;FF)V",
at = @At("HEAD"),
cancellable = true
)
private void rivencraft$mutePreviewLocalEntitySound(
Entity entity, SoundEvent sound, SoundSource source, float volume, float pitch,
CallbackInfo callback
) {
rivencraft$cancelPreviewSound(callback);
}
@Inject(
method = "playSeededSound(Lnet/minecraft/world/entity/player/Player;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/Holder;Lnet/minecraft/sounds/SoundSource;FFJ)V",
at = @At("HEAD"),
cancellable = true
)
private void rivencraft$mutePreviewSeededEntitySound(
Player player, Entity entity, Holder<SoundEvent> sound, SoundSource source,
float volume, float pitch, long seed, CallbackInfo callback
) {
rivencraft$cancelPreviewSound(callback);
}
private void rivencraft$cancelPreviewSound(CallbackInfo callback) {
if (ImmersivePortalsPreviewAudioGuard.shouldMute((ClientLevel)(Object)this)) {
callback.cancel();
}
}
}
@@ -19,6 +19,7 @@ public final class ModCreativeTabs {
.displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
output.accept(ModItems.LINKING_BOOK.get());
output.accept(ModItems.PAGE.get());
})
.build());
@@ -3,6 +3,8 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceLocation;
@@ -39,6 +41,18 @@ public final class ModDataComponents {
.networkSynchronized(ByteBufCodecs.STRING_UTF8)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<PageContent>> PAGE_CONTENT =
DATA_COMPONENTS.register("page_content", () -> DataComponentType.<PageContent>builder()
.persistent(PageContent.CODEC)
.networkSynchronized(PageContent.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<SymbolDisplayMetadata>> PAGE_SYMBOL_DISPLAY =
DATA_COMPONENTS.register("page_symbol_display", () -> DataComponentType.<SymbolDisplayMetadata>builder()
.persistent(SymbolDisplayMetadata.CODEC)
.networkSynchronized(SymbolDisplayMetadata.STREAM_CODEC)
.cacheEncoding()
.build());
private ModDataComponents() {
}
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.world.item.Item;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredItem;
@@ -21,6 +22,11 @@ public final class ModItems {
LinkingBookItem::new,
new Item.Properties().stacksTo(1)
);
public static final DeferredItem<PageItem> PAGE = ITEMS.registerItem(
"page",
PageItem::new,
new Item.Properties().stacksTo(64)
);
private ModItems() {
}
@@ -0,0 +1,47 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import io.netty.buffer.ByteBuf;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** Immutable ordered writing shared by pages, notebooks, desks, and books. */
public record OrderedWriting(List<WritingEntry> entries) {
public static final int MAX_ENTRIES = 256;
public static final OrderedWriting EMPTY = new OrderedWriting(List.of());
public static final Codec<OrderedWriting> CODEC = WritingEntry.CODEC.listOf()
.validate(entries -> entries.size() <= MAX_ENTRIES
? DataResult.success(entries)
: DataResult.error(() -> "Writing exceeds " + MAX_ENTRIES + " entries"))
.xmap(OrderedWriting::new, OrderedWriting::entries);
public static final StreamCodec<ByteBuf, OrderedWriting> STREAM_CODEC = StreamCodec.of(
OrderedWriting::encode, OrderedWriting::decode);
public OrderedWriting {
entries = List.copyOf(Objects.requireNonNull(entries, "entries"));
if (entries.size() > MAX_ENTRIES) {
throw new IllegalArgumentException("Writing exceeds " + MAX_ENTRIES + " entries");
}
}
private static void encode(ByteBuf buffer, OrderedWriting writing) {
ByteBufCodecs.VAR_INT.encode(buffer, writing.entries.size());
writing.entries.forEach(entry -> WritingEntry.STREAM_CODEC.encode(buffer, entry));
}
private static OrderedWriting decode(ByteBuf buffer) {
int size = ByteBufCodecs.VAR_INT.decode(buffer);
if (size < 0 || size > MAX_ENTRIES) {
throw new IllegalArgumentException("Invalid writing entry count " + size);
}
List<WritingEntry> entries = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
entries.add(WritingEntry.STREAM_CODEC.decode(buffer));
}
return new OrderedWriting(entries);
}
}
@@ -0,0 +1,95 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** Persistent contents of a Page item. Unknown symbols remain explicit and recoverable. */
public record PageContent(Kind kind, Optional<SymbolReference> symbol) {
public static final PageContent BLANK = new PageContent(Kind.BLANK, Optional.empty());
public static final PageContent LINK_PANEL = new PageContent(Kind.LINK_PANEL, Optional.empty());
private static final Codec<Kind> KIND_CODEC = Codec.STRING.comapFlatMap(
value -> {
try {
return DataResult.success(Kind.valueOf(value.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return DataResult.error(() -> "Unknown page content kind: " + value);
}
},
value -> value.name().toLowerCase(Locale.ROOT)
);
private static final Codec<PageContent> RECORD_CODEC = RecordCodecBuilder.create(instance -> instance.group(
KIND_CODEC.fieldOf("kind").forGetter(PageContent::kind),
SymbolReference.CODEC.optionalFieldOf("symbol").forGetter(PageContent::symbol)
).apply(instance, PageContent::new));
public static final Codec<PageContent> CODEC = RECORD_CODEC.flatXmap(PageContent::validate, PageContent::validate);
public static final StreamCodec<ByteBuf, PageContent> STREAM_CODEC = StreamCodec.of(
PageContent::encode, PageContent::decode);
public PageContent {
Objects.requireNonNull(kind, "kind");
symbol = Objects.requireNonNull(symbol, "symbol");
boolean needsSymbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL;
if (needsSymbol != symbol.isPresent()) {
throw new IllegalArgumentException("Page contents do not match kind " + kind);
}
}
public static PageContent symbol(SymbolReference symbol) {
return new PageContent(Kind.SYMBOL, Optional.of(symbol));
}
public static PageContent unresolved(SymbolReference symbol) {
return new PageContent(Kind.UNRESOLVED_SYMBOL, Optional.of(symbol));
}
/** Re-resolves persisted data against the authoritative current server catalog. */
public PageContent resolve() {
if (kind != Kind.SYMBOL && kind != Kind.UNRESOLVED_SYMBOL) {
return this;
}
SymbolReference reference = symbol.orElseThrow();
return SymbolCatalog.isKnown(reference) ? symbol(reference) : unresolved(reference);
}
public boolean isResolvedSymbol() {
return kind == Kind.SYMBOL;
}
private static DataResult<PageContent> validate(PageContent content) {
boolean needsSymbol = content.kind == Kind.SYMBOL || content.kind == Kind.UNRESOLVED_SYMBOL;
return needsSymbol == content.symbol.isPresent()
? DataResult.success(content)
: DataResult.error(() -> "Page contents do not match kind " + content.kind);
}
private static void encode(ByteBuf buffer, PageContent content) {
ByteBufCodecs.VAR_INT.encode(buffer, content.kind.ordinal());
content.symbol.ifPresent(reference -> SymbolReference.STREAM_CODEC.encode(buffer, reference));
}
private static PageContent decode(ByteBuf buffer) {
int ordinal = ByteBufCodecs.VAR_INT.decode(buffer);
if (ordinal < 0 || ordinal >= Kind.values().length) {
throw new IllegalArgumentException("Unknown page content kind ordinal " + ordinal);
}
Kind kind = Kind.values()[ordinal];
Optional<SymbolReference> symbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL
? Optional.of(SymbolReference.STREAM_CODEC.decode(buffer))
: Optional.empty();
return new PageContent(kind, symbol);
}
public enum Kind {
BLANK,
SYMBOL,
UNRESOLVED_SYMBOL,
LINK_PANEL
}
}
@@ -0,0 +1,67 @@
package net.dnorberg.rivencraft.writing;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.mojang.serialization.JsonOps;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import net.dnorberg.rivencraft.Rivencraft;
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.neoforged.neoforge.event.AddReloadListenerEvent;
/** Authoritative server catalog loaded from data/&lt;namespace&gt;/rivencraft/symbols. */
public final class SymbolCatalog {
private static final Gson GSON = new GsonBuilder().create();
private static final String DIRECTORY = "rivencraft/symbols";
private static volatile Map<ResourceLocation, SymbolDefinition> symbols = Map.of();
private SymbolCatalog() {
}
public static void register(AddReloadListenerEvent event) {
event.addListener(new Loader());
}
public static Optional<SymbolDefinition> find(SymbolReference reference) {
return Optional.ofNullable(symbols.get(reference.id()));
}
public static boolean isKnown(SymbolReference reference) {
return symbols.containsKey(reference.id());
}
public static SymbolDisplayMetadata display(SymbolReference reference) {
SymbolDefinition definition = symbols.get(reference.id());
return definition == null
? SymbolDisplayMetadata.unresolved(reference)
: definition.display(reference.id());
}
public static Map<ResourceLocation, SymbolDefinition> symbols() {
return symbols;
}
private static final class Loader extends SimpleJsonResourceReloadListener {
private Loader() {
super(GSON, DIRECTORY);
}
@Override
protected void apply(Map<ResourceLocation, JsonElement> resources,
ResourceManager resourceManager,
ProfilerFiller profiler) {
Map<ResourceLocation, SymbolDefinition> loaded = new HashMap<>();
resources.forEach((id, json) -> SymbolDefinition.CODEC.parse(JsonOps.INSTANCE, json)
.resultOrPartial(error -> Rivencraft.LOGGER.error(
"Ignoring invalid writing symbol {}: {}", id, error))
.ifPresent(definition -> loaded.put(id, definition)));
symbols = Map.copyOf(loaded);
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
}
}
}
@@ -0,0 +1,47 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.Objects;
import java.util.regex.Pattern;
import net.minecraft.resources.ResourceLocation;
/** Server-loaded, non-executable display metadata for a writing symbol. */
public record SymbolDefinition(
int schemaVersion,
String nameTranslationKey,
String descriptionTranslationKey,
ResourceLocation category
) {
public static final int CURRENT_SCHEMA_VERSION = 1;
private static final Pattern TRANSLATION_KEY = Pattern.compile("[a-z0-9_.-]+");
private static final Codec<String> TRANSLATION_KEY_CODEC = Codec.STRING.comapFlatMap(
value -> TRANSLATION_KEY.matcher(value).matches()
? DataResult.success(value)
: DataResult.error(() -> "Invalid translation key: " + value),
value -> value
);
public static final Codec<SymbolDefinition> CODEC = RecordCodecBuilder.<SymbolDefinition>create(instance -> instance.group(
Codec.INT.fieldOf("schema_version").forGetter(SymbolDefinition::schemaVersion),
TRANSLATION_KEY_CODEC.fieldOf("name").forGetter(SymbolDefinition::nameTranslationKey),
TRANSLATION_KEY_CODEC.fieldOf("description").forGetter(SymbolDefinition::descriptionTranslationKey),
ResourceLocation.CODEC.fieldOf("category").forGetter(SymbolDefinition::category)
).apply(instance, SymbolDefinition::new)).flatXmap(SymbolDefinition::validate, SymbolDefinition::validate);
public SymbolDefinition {
Objects.requireNonNull(nameTranslationKey, "nameTranslationKey");
Objects.requireNonNull(descriptionTranslationKey, "descriptionTranslationKey");
Objects.requireNonNull(category, "category");
}
public SymbolDisplayMetadata display(ResourceLocation id) {
return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true);
}
private static DataResult<SymbolDefinition> validate(SymbolDefinition definition) {
return definition.schemaVersion == CURRENT_SCHEMA_VERSION
? DataResult.success(definition)
: DataResult.error(() -> "Unsupported symbol schema_version " + definition.schemaVersion);
}
}
@@ -0,0 +1,44 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.ResourceLocation;
/** Client-safe symbol presentation selected by the authoritative server catalog. */
public record SymbolDisplayMetadata(
ResourceLocation id,
String nameTranslationKey,
String descriptionTranslationKey,
ResourceLocation category,
boolean resolved
) {
public static final Codec<SymbolDisplayMetadata> CODEC = RecordCodecBuilder.create(instance -> instance.group(
ResourceLocation.CODEC.fieldOf("id").forGetter(SymbolDisplayMetadata::id),
Codec.STRING.fieldOf("name").forGetter(SymbolDisplayMetadata::nameTranslationKey),
Codec.STRING.fieldOf("description").forGetter(SymbolDisplayMetadata::descriptionTranslationKey),
ResourceLocation.CODEC.fieldOf("category").forGetter(SymbolDisplayMetadata::category),
Codec.BOOL.fieldOf("resolved").forGetter(SymbolDisplayMetadata::resolved)
).apply(instance, SymbolDisplayMetadata::new));
public static final StreamCodec<ByteBuf, SymbolDisplayMetadata> STREAM_CODEC = StreamCodec.composite(
ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::id,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::nameTranslationKey,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::descriptionTranslationKey,
ResourceLocation.STREAM_CODEC, SymbolDisplayMetadata::category,
ByteBufCodecs.BOOL, SymbolDisplayMetadata::resolved,
SymbolDisplayMetadata::new
);
public static SymbolDisplayMetadata unresolved(SymbolReference reference) {
return new SymbolDisplayMetadata(
reference.id(),
"symbol.rivencraft.unresolved",
"symbol.rivencraft.unresolved.description",
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "unresolved"),
false
);
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import io.netty.buffer.ByteBuf;
import java.util.Objects;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.ResourceLocation;
/** Stable namespaced identity for one writing symbol. */
public record SymbolReference(ResourceLocation id) {
public static final Codec<SymbolReference> CODEC = ResourceLocation.CODEC.xmap(
SymbolReference::new, SymbolReference::id);
public static final StreamCodec<ByteBuf, SymbolReference> STREAM_CODEC = ResourceLocation.STREAM_CODEC.map(
SymbolReference::new, SymbolReference::id);
public SymbolReference {
Objects.requireNonNull(id, "id");
}
/** Remains useful when the defining data pack has been removed. */
public String unresolvedName() {
return id.toString();
}
}
@@ -0,0 +1,82 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** One ordered unit of book writing. Interpretation belongs to Milestone 5. */
public record WritingEntry(Kind kind, Optional<SymbolReference> symbol) {
private static final Codec<Kind> KIND_CODEC = Codec.STRING.comapFlatMap(
value -> {
try {
return DataResult.success(Kind.valueOf(value.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return DataResult.error(() -> "Unknown writing entry kind: " + value);
}
},
value -> value.name().toLowerCase(Locale.ROOT)
);
private static final Codec<WritingEntry> RECORD_CODEC = RecordCodecBuilder.create(instance -> instance.group(
KIND_CODEC.fieldOf("kind").forGetter(WritingEntry::kind),
SymbolReference.CODEC.optionalFieldOf("symbol").forGetter(WritingEntry::symbol)
).apply(instance, WritingEntry::new));
public static final Codec<WritingEntry> CODEC = RECORD_CODEC.flatXmap(
WritingEntry::validate, WritingEntry::validate);
public static final StreamCodec<ByteBuf, WritingEntry> STREAM_CODEC = StreamCodec.of(
WritingEntry::encode, WritingEntry::decode);
public WritingEntry {
Objects.requireNonNull(kind, "kind");
symbol = Objects.requireNonNull(symbol, "symbol");
if (kind == Kind.SYMBOL && symbol.isEmpty()) {
throw new IllegalArgumentException("Symbol writing entries require a symbol reference");
}
if (kind == Kind.LINK_PANEL && symbol.isPresent()) {
throw new IllegalArgumentException("Link-panel writing entries cannot contain a symbol reference");
}
}
public static WritingEntry symbol(SymbolReference symbol) {
return new WritingEntry(Kind.SYMBOL, Optional.of(symbol));
}
public static WritingEntry linkPanel() {
return new WritingEntry(Kind.LINK_PANEL, Optional.empty());
}
private static DataResult<WritingEntry> validate(WritingEntry entry) {
return entry.kind == Kind.SYMBOL && entry.symbol.isEmpty()
|| entry.kind == Kind.LINK_PANEL && entry.symbol.isPresent()
? DataResult.error(() -> "Writing entry contents do not match kind " + entry.kind)
: DataResult.success(entry);
}
private static void encode(ByteBuf buffer, WritingEntry entry) {
ByteBufCodecs.VAR_INT.encode(buffer, entry.kind.ordinal());
if (entry.kind == Kind.SYMBOL) {
SymbolReference.STREAM_CODEC.encode(buffer, entry.symbol.orElseThrow());
}
}
private static WritingEntry decode(ByteBuf buffer) {
int ordinal = ByteBufCodecs.VAR_INT.decode(buffer);
if (ordinal < 0 || ordinal >= Kind.values().length) {
throw new IllegalArgumentException("Unknown writing entry kind ordinal " + ordinal);
}
Kind kind = Kind.values()[ordinal];
return kind == Kind.SYMBOL
? symbol(SymbolReference.STREAM_CODEC.decode(buffer))
: linkPanel();
}
public enum Kind {
SYMBOL,
LINK_PANEL
}
}
@@ -6,6 +6,12 @@
"item.rivencraft.linking_book.unbound": "Unbound — sneak-use to bind",
"item.rivencraft.linking_book.destination": "%s (%s, %s, %s)",
"item.rivencraft.linking_book.return": "Return Link",
"item.rivencraft.page": "Page",
"item.rivencraft.page.blank": "Blank Page",
"item.rivencraft.page.blank.description": "Ready for writing at a Writing Desk.",
"item.rivencraft.page.link_panel": "Link Panel Page",
"item.rivencraft.page.link_panel.description": "The baseline panel required to assemble a Descriptive Book.",
"item.rivencraft.page.unresolved": "Unresolved Symbol Page",
"message.rivencraft.linking_book.bind_hint": "Sneak-use to bind this book to your current location",
"message.rivencraft.linking_book.unbound": "This Linking Book is unbound",
"message.rivencraft.linking_book.bound": "Linking Book bound as %s",
@@ -57,6 +63,20 @@
"trait.rivencraft.weather.snow": "Snow",
"trait.rivencraft.weather.storm": "Storm",
"trait.rivencraft.day_length.stopped": "Stopped",
"symbol.rivencraft.unresolved": "Unresolved Symbol",
"symbol.rivencraft.unresolved.description": "Missing symbol: %s",
"symbol.rivencraft.terrain.standard": "Standard Terrain",
"symbol.rivencraft.terrain.standard.description": "A familiar continental terrain foundation.",
"symbol.rivencraft.biome.native": "Native Biomes",
"symbol.rivencraft.biome.native.description": "Use the terrain profile's native biome climate.",
"symbol.rivencraft.lighting.normal": "Normal Lighting",
"symbol.rivencraft.lighting.normal.description": "Use normal skylight and ambient brightness.",
"symbol.rivencraft.weather.normal": "Normal Weather",
"symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.",
"symbol.rivencraft.celestial.normal_time": "Normal Time",
"symbol.rivencraft.celestial.normal_time.description": "Use a normal-length celestial cycle.",
"symbol.rivencraft.feature.caves": "Caves",
"symbol.rivencraft.feature.caves.description": "Describe underground cave features.",
"generator.rivencraft.standard": "Standard",
"generator.rivencraft.amplified": "Amplified",
"generator.rivencraft.island": "Island",
@@ -1,6 +1,14 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/linking_book"
}
"layer0": "rivencraft:item/linking_book_unwritten"
},
"overrides": [
{
"predicate": {
"rivencraft:bound": 1.0
},
"model": "rivencraft:item/linking_book_bound"
}
]
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/linking_book"
}
}
@@ -0,0 +1,17 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/page"
},
"overrides": [
{ "predicate": { "custom_model_data": 1 }, "model": "rivencraft:item/page_link_panel" },
{ "predicate": { "custom_model_data": 2 }, "model": "rivencraft:item/page_unresolved" },
{ "predicate": { "custom_model_data": 3 }, "model": "rivencraft:item/page_symbol" },
{ "predicate": { "custom_model_data": 100 }, "model": "rivencraft:item/page_symbol_terrain_standard" },
{ "predicate": { "custom_model_data": 101 }, "model": "rivencraft:item/page_symbol_biome_native" },
{ "predicate": { "custom_model_data": 102 }, "model": "rivencraft:item/page_symbol_lighting_normal" },
{ "predicate": { "custom_model_data": 103 }, "model": "rivencraft:item/page_symbol_weather_normal" },
{ "predicate": { "custom_model_data": 104 }, "model": "rivencraft:item/page_symbol_celestial_normal_time" },
{ "predicate": { "custom_model_data": 105 }, "model": "rivencraft:item/page_symbol_feature_caves" }
]
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/page_link_panel"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/page_symbol"
}
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_biome_native" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_celestial_normal_time" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_feature_caves" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_lighting_normal" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_terrain_standard" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_weather_normal" }
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/page_unresolved"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,13 @@
{
"type": "minecraft:crafting_shapeless",
"category": "misc",
"ingredients": [
{
"item": "minecraft:paper"
}
],
"result": {
"count": 1,
"id": "rivencraft:page"
}
}
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"name": "symbol.rivencraft.biome.native",
"description": "symbol.rivencraft.biome.native.description",
"category": "rivencraft:biome"
}
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"name": "symbol.rivencraft.celestial.normal_time",
"description": "symbol.rivencraft.celestial.normal_time.description",
"category": "rivencraft:celestial"
}
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"name": "symbol.rivencraft.feature.caves",
"description": "symbol.rivencraft.feature.caves.description",
"category": "rivencraft:feature"
}
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"name": "symbol.rivencraft.lighting.normal",
"description": "symbol.rivencraft.lighting.normal.description",
"category": "rivencraft:lighting"
}
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"name": "symbol.rivencraft.terrain.standard",
"description": "symbol.rivencraft.terrain.standard.description",
"category": "rivencraft:terrain"
}
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"name": "symbol.rivencraft.weather.normal",
"description": "symbol.rivencraft.weather.normal.description",
"category": "rivencraft:weather"
}
@@ -4,6 +4,7 @@
"package": "net.dnorberg.rivencraft.mixin",
"compatibilityLevel": "JAVA_21",
"client": [
"client.ClientLevelSoundMixin",
"client.LevelRendererMixin"
],
"injectors": {