Compare commits

...
11 Commits
Author SHA1 Message Date
dnorberg 956b91c462 Match Legacy notebook page surface
Build / build (push) Canceled after 0s
2026-09-24 04:17:19 -04:00
dnorberg c980366278 Refine notebook screen layout
Build / build (push) Canceled after 0s
2026-09-23 23:28:04 -04:00
dnorberg 616e3d2af5 Add notebook page storage and menu (#19)
Build / build (push) Canceled after 0s
2026-09-23 23:21:09 -04:00
dnorberg 7d335f5952 Bug Fix: Enviromental Audio from Immersive Portals
Build / build (push) Canceled after 0s
2026-09-23 15:32:24 -04:00
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
72 changed files with 2232 additions and 17 deletions

No files matched your search

+15 -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
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.
+8 -1
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.
@@ -61,9 +62,15 @@ 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
+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.29.0
mod_version=0.3.3.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.
@@ -4,6 +4,7 @@ import com.mojang.logging.LogUtils;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.registry.ModSounds;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
@@ -18,6 +19,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;
@@ -40,6 +42,7 @@ public final class Rivencraft {
ModBlocks.register(modEventBus);
ModBlockEntities.register(modEventBus);
ModItems.register(modEventBus);
ModMenus.register(modEventBus);
ModSounds.register(modEventBus);
ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
@@ -50,6 +53,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,12 +2,19 @@ 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;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.dnorberg.rivencraft.client.screen.NotebookScreen;
import net.dnorberg.rivencraft.registry.ModMenus;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class ClientEvents {
@@ -19,8 +26,18 @@ public final class ClientEvents {
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
}
@SubscribeEvent
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new);
}
@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);
@@ -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();
}
@@ -118,6 +122,7 @@ public final class ImmersivePortalsDestinationPreviewRenderer implements Destina
FRAMEBUFFER.clear(true);
minecraft.getMainRenderTarget().bindWrite(true);
MyRenderHelper.restoreViewPort();
ImmersivePortalsPreviewAudioGuard.markCameraRestorePending();
GuiPortalRendering.submitNextFrameRendering(info, FRAMEBUFFER);
hasCompletedFrame = true;
graphics.renderOutline(left, top, width, height, exploring ? 0xFFE0C36A : 0xFF7B6AA8);
@@ -234,5 +239,8 @@ public final class ImmersivePortalsDestinationPreviewRenderer implements Destina
public void close() {
clearMovement();
hasCompletedFrame = false;
if (destination != null) {
ImmersivePortalsPreviewAudioGuard.clear(destination.dimension());
}
}
}
@@ -0,0 +1,62 @@
package net.dnorberg.rivencraft.client.preview;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Camera;
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 static boolean cameraRestorePending;
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());
}
public static void markCameraRestorePending() {
cameraRestorePending = true;
}
/**
* Immersive Portals' GUI renderer leaves the shared main camera in the
* remote world after its end-of-frame render. Restore it before the next
* frame uses that camera for rain effects and the sound listener.
*/
public static void restorePlayerCamera() {
Minecraft minecraft = Minecraft.getInstance();
if (!cameraRestorePending) {
return;
}
cameraRestorePending = false;
if (minecraft.level == null || minecraft.getCameraEntity() == null) {
return;
}
Camera camera = minecraft.gameRenderer.getMainCamera();
camera.setup(
minecraft.level,
minecraft.getCameraEntity(),
!minecraft.options.getCameraType().isFirstPerson(),
minecraft.options.getCameraType().isMirrored(),
camera.getPartialTickTime()
);
}
}
@@ -0,0 +1,328 @@
package net.dnorberg.rivencraft.client.screen;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.network.RenameNotebookPagePayload;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor;
/** A page surface modelled on Legacy's folder GUI, backed by the Notebook menu. */
public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu> {
private static final int SURFACE_TOP = 20;
private static final int SURFACE_BOTTOM = 132;
private static final int SURFACE_RIGHT = 156;
private static final int PAGE_WIDTH = 30;
private static final int PAGE_HEIGHT = 40;
private static final int PAGE_STEP_X = 31;
private static final int PAGE_STEP_Y = 41;
private static final int PAGE_COLUMNS = 5;
private EditBox textInput;
private boolean renaming;
private boolean draggingScrollbar;
private boolean surfaceClickPendingRelease;
private int selectedSlot = -1;
private int scroll;
public NotebookScreen(NotebookMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
imageWidth = 176;
imageHeight = 213;
inventoryLabelY = 123;
}
@Override
protected void init() {
super.init();
Button sortButton = Button.builder(Component.literal("AZ"), button -> {})
.bounds(leftPos, topPos, 18, 18).build();
Button allButton = Button.builder(Component.literal("ALL"), button -> {})
.bounds(leftPos + 20, topPos, 18, 18).build();
sortButton.active = false;
allButton.active = false;
addRenderableWidget(sortButton);
addRenderableWidget(allButton);
textInput = new EditBox(font, leftPos + 40, topPos, 94, 18,
Component.translatable("screen.rivencraft.notebook.search"));
textInput.setMaxLength(32);
textInput.setHint(Component.translatable("screen.rivencraft.notebook.search"));
textInput.setResponder(value -> {
if (!renaming) scroll = 0;
});
addRenderableWidget(textInput);
addRenderableWidget(Button.builder(Component.literal("R"), button -> toggleRename())
.bounds(leftPos + 136, topPos, 18, 18)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.notebook.rename_toggle")))
.build());
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
List<Integer> pages = visiblePages();
scroll = Math.min(scroll, maxScroll(pages.size()));
graphics.enableScissor(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM);
for (int position = 0; position < pages.size(); position++) {
int x = leftPos + (position % PAGE_COLUMNS) * PAGE_STEP_X;
int y = topPos + SURFACE_TOP + (position / PAGE_COLUMNS) * PAGE_STEP_Y - scroll;
if (y + PAGE_HEIGHT > topPos + SURFACE_TOP && y < topPos + SURFACE_BOTTOM) {
drawPage(graphics, pages.get(position), x, y);
}
}
graphics.disableScissor();
drawScrollbar(graphics, pages.size());
int hovered = pageAt(mouseX, mouseY, pages);
if (!menu.getCarried().isEmpty() && inSurface(mouseX, mouseY)) {
graphics.renderItem(menu.getCarried(), mouseX - 8, mouseY - 8);
} else if (hovered >= 0) {
ItemStack page = menu.getSlot(hovered).getItem();
if (!page.isEmpty() && matchesSearch(page)) graphics.renderTooltip(font, page, mouseX, mouseY);
} else {
renderTooltip(graphics, mouseX, mouseY);
}
}
@Override
protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
graphics.fill(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM, 0xB0000000);
drawBeveledPanel(graphics, leftPos, topPos + 132, imageWidth, 81);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 134 + row * 18);
}
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 192);
}
@Override
protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (inScrollbar(mouseX, mouseY) && button == 0) {
draggingScrollbar = true;
setScrollFromMouse(mouseY, visiblePages().size());
return true;
}
int index = pageAt(mouseX, mouseY, visiblePages());
if (index >= 0 && (button == 0 || button == 1)) {
ItemStack page = menu.getSlot(index).getItem();
if (!page.isEmpty() && !matchesSearch(page)) return true;
if (renaming) finishRename();
setFocused(null);
textInput.setFocused(false);
selectedSlot = index;
slotClicked(menu.getSlot(index), index, button, ClickType.PICKUP);
surfaceClickPendingRelease = true;
return true;
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
if (draggingScrollbar && button == 0) {
setScrollFromMouse(mouseY, visiblePages().size());
return true;
}
return super.mouseDragged(mouseX, mouseY, button, dragX, dragY);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
if (draggingScrollbar || surfaceClickPendingRelease) {
draggingScrollbar = false;
surfaceClickPendingRelease = false;
return true;
}
draggingScrollbar = false;
return super.mouseReleased(mouseX, mouseY, button);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (inSurface(mouseX, mouseY) || inScrollbar(mouseX, mouseY)) {
scroll = Math.clamp(scroll - (int)Math.signum(scrollY) * 20,
0, maxScroll(visiblePages().size()));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (renaming && textInput.isFocused() && (keyCode == 257 || keyCode == 335)) {
finishRename();
return true;
}
if (!textInput.isFocused() && keyCode == 82 && selectedSlot >= 0) {
toggleRename();
return true;
}
if (!textInput.isFocused() && selectedSlot >= 0 && (keyCode == 263 || keyCode == 262)) {
moveSelected(keyCode == 262);
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
@Override
public void onClose() {
commitRename();
super.onClose();
}
private List<Integer> visiblePages() {
List<Integer> pages = new ArrayList<>();
int firstEmpty = -1;
for (int index = 0; index < NotebookMenu.NOTEBOOK_SLOTS; index++) {
ItemStack stack = menu.getSlot(index).getItem();
if (stack.isEmpty()) {
if (firstEmpty < 0) firstEmpty = index;
} else {
pages.add(index);
}
}
if (firstEmpty >= 0 && menu.getCarried().is(ModItems.PAGE.get())) {
pages.add(firstEmpty);
}
return pages;
}
private boolean matchesSearch(ItemStack page) {
return renaming || textInput.getValue().isBlank()
|| page.getHoverName().getString().toLowerCase(Locale.ROOT)
.contains(textInput.getValue().toLowerCase(Locale.ROOT));
}
private int pageAt(double mouseX, double mouseY, List<Integer> pages) {
if (!inSurface(mouseX, mouseY)) return -1;
int localX = (int)mouseX - leftPos;
int localY = (int)mouseY - topPos - SURFACE_TOP + scroll;
int column = localX / PAGE_STEP_X;
int position = (localY / PAGE_STEP_Y) * PAGE_COLUMNS + column;
return column < PAGE_COLUMNS && localX % PAGE_STEP_X < PAGE_WIDTH
&& localY % PAGE_STEP_Y < PAGE_HEIGHT && position < pages.size()
? pages.get(position) : -1;
}
private boolean inSurface(double mouseX, double mouseY) {
return mouseX >= leftPos && mouseX < leftPos + SURFACE_RIGHT
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private boolean inScrollbar(double mouseX, double mouseY) {
return mouseX >= leftPos + SURFACE_RIGHT && mouseX < leftPos + imageWidth
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private int maxScroll(int pageCount) {
int rows = (pageCount + PAGE_COLUMNS - 1) / PAGE_COLUMNS;
return Math.max(0, rows * PAGE_STEP_Y - (SURFACE_BOTTOM - SURFACE_TOP));
}
private void setScrollFromMouse(double mouseY, int pageCount) {
int maximum = maxScroll(pageCount);
double fraction = (mouseY - topPos - SURFACE_TOP - 10.0)
/ (SURFACE_BOTTOM - SURFACE_TOP - 20.0);
scroll = Math.clamp((int)(Math.clamp(fraction, 0.0, 1.0) * maximum), 0, maximum);
}
private void drawScrollbar(GuiGraphics graphics, int pageCount) {
int x = leftPos + SURFACE_RIGHT;
int y = topPos + SURFACE_TOP;
int height = SURFACE_BOTTOM - SURFACE_TOP;
graphics.fill(x, y, x + 20, y + height, 0xFF858585);
graphics.renderOutline(x, y, 20, height, 0xFF262626);
for (int line = y + 5; line < y + height - 4; line += 5) {
graphics.fill(x + 4, line, x + 16, line + 1, 0xFFB7B7B7);
}
int maximum = maxScroll(pageCount);
int thumbY = y + 4 + (maximum == 0 ? 0 : scroll * (height - 23) / maximum);
graphics.fill(x + 4, thumbY, x + 16, thumbY + 15, 0xFFC9C9C9);
graphics.renderOutline(x + 4, thumbY, 12, 15, 0xFF414141);
}
private void drawPage(GuiGraphics graphics, int index, int x, int y) {
ItemStack page = menu.getSlot(index).getItem();
boolean visible = !page.isEmpty() && matchesSearch(page);
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT,
visible ? 0xFFE5D4A7 : 0x704B4945);
graphics.fill(x + 2, y + 2, x + PAGE_WIDTH - 2, y + PAGE_HEIGHT - 2,
visible ? 0xFFF4E5C3 : 0x603B3934);
graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT,
index == selectedSlot ? 0xFFFFD061 : 0xFF564A34);
if (visible) {
graphics.pose().pushPose();
graphics.pose().translate(x + 3, y + 8, 0);
graphics.pose().scale(1.5F, 1.5F, 1.0F);
graphics.renderItem(page, 0, 0);
graphics.pose().popPose();
}
}
private void toggleRename() {
if (renaming) {
finishRename();
} else if (selectedSlot >= 0 && !menu.getSlot(selectedSlot).getItem().isEmpty()) {
renaming = true;
textInput.setValue(menu.getSlot(selectedSlot).getItem().getHoverName().getString());
textInput.setHint(Component.translatable("screen.rivencraft.notebook.rename"));
setFocused(textInput);
textInput.setFocused(true);
}
}
private void finishRename() {
commitRename();
renaming = false;
textInput.setValue("");
textInput.setHint(Component.translatable("screen.rivencraft.notebook.search"));
}
private void commitRename() {
if (renaming && selectedSlot >= 0 && !menu.getSlot(selectedSlot).getItem().isEmpty()) {
PacketDistributor.sendToServer(new RenameNotebookPagePayload(
menu.containerId, selectedSlot, textInput.getValue()));
}
}
private void moveSelected(boolean next) {
int target = selectedSlot + (next ? 1 : -1);
if (selectedSlot < 0 || target < 0 || target >= NotebookMenu.NOTEBOOK_SLOTS
|| menu.getSlot(selectedSlot).getItem().isEmpty()) return;
int buttonId = (next ? NotebookMenu.MOVE_NEXT_BASE : NotebookMenu.MOVE_PREVIOUS_BASE) + selectedSlot;
if (menu.clickMenuButton(minecraft.player, buttonId)) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
selectedSlot = target;
}
}
private static void drawBeveledPanel(GuiGraphics graphics, int x, int y, int width, int height) {
graphics.fill(x, y, x + width, y + height, 0xFFC6C6C6);
graphics.fill(x, y, x + width, y + 2, 0xFFFFFFFF);
graphics.fill(x, y, x + 2, y + height, 0xFFFFFFFF);
graphics.fill(x, y + height - 2, x + width, y + height, 0xFF555555);
graphics.fill(x + width - 2, y, x + width, y + height, 0xFF555555);
}
private void drawSlot(GuiGraphics graphics, int x, int y) {
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFF8B8B8B);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 1, 0xFF373737);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 1, topPos + y + 18, 0xFF373737);
graphics.fill(leftPos + x + 17, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
graphics.fill(leftPos + x, topPos + y + 17, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
}
}
@@ -241,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();
@@ -529,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);
@@ -538,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,182 @@
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.writing.NotebookContents;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.menu.NotebookMenu;
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.minecraft.world.item.Items;
import net.minecraft.world.InteractionHand;
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();
}
}
@GameTest(template = "empty")
public static void notebookContentsPreserveOrderGapsNamesAndUnknownPages(GameTestHelper helper) {
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
first.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,
net.minecraft.network.chat.Component.literal("Foundation"));
ItemStack unknown = PageItem.createRecoveredPage(reference("example:removed_symbol"));
NotebookContents contents = new NotebookContents(List.of(first, ItemStack.EMPTY, unknown));
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), contents);
CompoundTag serialized = (CompoundTag)notebook.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized);
NotebookContents restoredContents = restored.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
helper.assertTrue(restoredContents != null, "Notebook contents must survive ItemStack persistence");
helper.assertValueEqual(restoredContents.pages().size(), 3,
"Notebook persistence must retain ordered positions");
helper.assertTrue(restoredContents.pages().get(1).isEmpty(),
"Notebook persistence must retain empty positions between pages");
helper.assertValueEqual(restoredContents.pages().get(0).getHoverName().getString(), "Foundation",
"Renamed contained pages must retain their names");
helper.assertValueEqual(PageItem.content(restoredContents.pages().get(2)).kind(),
PageContent.Kind.UNRESOLVED_SYMBOL,
"Unknown symbol pages must remain present and removable");
helper.succeed();
}
@GameTest(template = "empty")
public static void notebookMenuValidatesInsertionAndPersistsReorderAndRename(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack notebook = new ItemStack(ModItems.NOTEBOOK.get());
player.setItemInHand(InteractionHand.MAIN_HAND, notebook);
NotebookMenu menu = new NotebookMenu(7, player.getInventory(), InteractionHand.MAIN_HAND);
ItemStack first = PageItem.createSymbolPage(reference("rivencraft:terrain/standard"));
ItemStack second = PageItem.createRecoveredPage(reference("example:removed_symbol"));
helper.assertFalse(menu.getSlot(0).mayPlace(new ItemStack(Items.DIRT)),
"Notebook slots must reject non-Page items");
menu.getSlot(0).set(first);
menu.getSlot(1).set(second);
menu.renamePage(0, "Terrain Base");
helper.assertTrue(menu.clickMenuButton(player, NotebookMenu.MOVE_NEXT_BASE),
"The server menu must accept a valid adjacent reorder");
NotebookContents saved = notebook.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
helper.assertTrue(saved != null, "Menu changes must persist immediately to the Notebook item");
helper.assertValueEqual(saved.pages().get(1).getHoverName().getString(), "Terrain Base",
"Reordering must preserve the renamed Page stack");
helper.assertValueEqual(PageItem.content(saved.pages().get(0)).kind(),
PageContent.Kind.UNRESOLVED_SYMBOL,
"Reordering must preserve unresolved pages");
helper.succeed();
}
private static SymbolReference reference(String id) {
return new SymbolReference(ResourceLocation.parse(id));
}
}
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
public final class NotebookItem extends Item {
public NotebookItem(Properties properties) {
super(properties);
}
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
ItemStack notebook = player.getItemInHand(hand);
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer) {
serverPlayer.openMenu(new SimpleMenuProvider(
(containerId, inventory, ignored) -> new NotebookMenu(containerId, inventory, hand),
notebook.getHoverName()),
buffer -> buffer.writeBoolean(hand == InteractionHand.OFF_HAND));
}
return InteractionResultHolder.sidedSuccess(notebook, level.isClientSide);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context,
List<Component> tooltip, TooltipFlag flag) {
NotebookContents contents = stack.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
tooltip.add(Component.translatable("item.rivencraft.notebook.pages",
contents.pageCount(), NotebookContents.MAX_PAGES).withStyle(ChatFormatting.GRAY));
if (contents.unresolvedCount() > 0) {
tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved",
contents.unresolvedCount()).withStyle(ChatFormatting.RED));
}
}
}
@@ -0,0 +1,135 @@
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) {
Component customName = stack.get(DataComponents.CUSTOM_NAME);
if (customName != null) {
return customName;
}
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,201 @@
package net.dnorberg.rivencraft.menu;
import java.util.List;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.NonNullList;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
public final class NotebookMenu extends AbstractContainerMenu {
public static final int NOTEBOOK_SLOTS = NotebookContents.MAX_PAGES;
public static final int MOVE_PREVIOUS_BASE = 0;
public static final int MOVE_NEXT_BASE = NOTEBOOK_SLOTS;
private static final int PLAYER_INVENTORY_START = NOTEBOOK_SLOTS;
private final Inventory playerInventory;
private final InteractionHand hand;
private final ItemStack notebook;
private final NotebookContainer contents;
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
}
public NotebookMenu(int containerId, Inventory inventory, InteractionHand hand) {
super(ModMenus.NOTEBOOK.get(), containerId);
this.playerInventory = inventory;
this.hand = hand;
this.notebook = inventory.player.getItemInHand(hand);
this.contents = new NotebookContainer(notebook);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
// The client draws these pages on a filtered, scrollable surface instead.
addSlot(new PageSlot(contents, column + row * 9, -1000, -1000));
}
}
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
int inventoryIndex = column + row * 9 + 9;
addSlot(playerSlot(inventoryIndex, 8 + column * 18, 135 + row * 18));
}
}
for (int column = 0; column < 9; column++) {
addSlot(playerSlot(column, 8 + column * 18, 193));
}
}
private Slot playerSlot(int inventoryIndex, int x, int y) {
boolean locked = hand == InteractionHand.MAIN_HAND && inventoryIndex == playerInventory.selected;
return locked ? new LockedSlot(playerInventory, inventoryIndex, x, y)
: new Slot(playerInventory, inventoryIndex, x, y);
}
@Override
public boolean stillValid(Player player) {
return player.isAlive() && player.getItemInHand(hand) == notebook && notebook.is(ModItems.NOTEBOOK.get());
}
@Override
public ItemStack quickMoveStack(Player player, int index) {
Slot slot = slots.get(index);
if (!slot.hasItem()) {
return ItemStack.EMPTY;
}
ItemStack source = slot.getItem();
ItemStack original = source.copy();
if (index < PLAYER_INVENTORY_START) {
if (!moveItemStackTo(source, PLAYER_INVENTORY_START, slots.size(), true)) {
return ItemStack.EMPTY;
}
} else {
if (!source.is(ModItems.PAGE.get())
|| !moveItemStackTo(source, 0, PLAYER_INVENTORY_START, false)) {
return ItemStack.EMPTY;
}
}
if (source.isEmpty()) {
slot.setByPlayer(ItemStack.EMPTY);
} else {
slot.setChanged();
}
return original;
}
@Override
public boolean clickMenuButton(Player player, int id) {
int source;
int target;
if (id >= MOVE_PREVIOUS_BASE && id < MOVE_NEXT_BASE) {
source = id;
target = source - 1;
} else if (id >= MOVE_NEXT_BASE && id < MOVE_NEXT_BASE + NOTEBOOK_SLOTS) {
source = id - MOVE_NEXT_BASE;
target = source + 1;
} else {
return false;
}
if (source < 0 || source >= NOTEBOOK_SLOTS || target < 0 || target >= NOTEBOOK_SLOTS
|| contents.getItem(source).isEmpty()) {
return false;
}
contents.swap(source, target);
broadcastChanges();
return true;
}
public void renamePage(int slot, String name) {
if (slot < 0 || slot >= NOTEBOOK_SLOTS) {
return;
}
ItemStack page = contents.getItem(slot);
if (page.isEmpty() || !page.is(ModItems.PAGE.get())) {
return;
}
if (name.isBlank()) {
page.remove(DataComponents.CUSTOM_NAME);
} else {
page.set(DataComponents.CUSTOM_NAME, Component.literal(name));
}
contents.setChanged();
broadcastChanges();
}
@Override
public void clicked(int slotId, int button, ClickType clickType, Player player) {
super.clicked(slotId, button, clickType, player);
if (!stillValid(player)) {
player.closeContainer();
}
}
private static final class PageSlot extends Slot {
private PageSlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
@Override public boolean mayPlace(ItemStack stack) { return stack.is(ModItems.PAGE.get()); }
@Override public int getMaxStackSize() { return 1; }
}
private static final class LockedSlot extends Slot {
private LockedSlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
@Override public boolean mayPickup(Player player) { return false; }
@Override public boolean mayPlace(ItemStack stack) { return false; }
}
private static final class NotebookContainer extends SimpleContainer {
private final ItemStack notebook;
private boolean loading;
private NotebookContainer(ItemStack notebook) {
super(NOTEBOOK_SLOTS);
this.notebook = notebook;
loading = true;
List<ItemStack> saved = notebook.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
for (int index = 0; index < saved.size(); index++) {
super.setItem(index, saved.get(index).copy());
}
loading = false;
}
private void swap(int first, int second) {
loading = true;
ItemStack held = getItem(first);
setItem(first, getItem(second));
setItem(second, held);
loading = false;
setChanged();
}
@Override
public void setChanged() {
super.setChanged();
if (loading || notebook.isEmpty()) {
return;
}
NonNullList<ItemStack> saved = NonNullList.withSize(NOTEBOOK_SLOTS, ItemStack.EMPTY);
for (int index = 0; index < NOTEBOOK_SLOTS; index++) {
ItemStack page = getItem(index);
saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1));
}
notebook.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(saved));
}
}
}
@@ -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();
}
}
}
@@ -0,0 +1,18 @@
package net.dnorberg.rivencraft.mixin.client;
import net.dnorberg.rivencraft.client.preview.ImmersivePortalsPreviewAudioGuard;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Restores the player camera after Immersive Portals finishes GUI-world rendering. */
@Pseudo
@Mixin(targets = "qouteall.imm_ptl.core.render.GuiPortalRendering", remap = false)
public abstract class ImmersivePortalsGuiRenderingMixin {
@Inject(method = "_onGameRenderEnd", at = @At("RETURN"), remap = false)
private static void rivencraft$restorePlayerCamera(CallbackInfo callback) {
ImmersivePortalsPreviewAudioGuard.restorePlayerCamera();
}
}
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.client.ClientAgeCelestialAngles;
import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.component.DataComponents;
@@ -48,7 +49,9 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("9");
PayloadRegistrar registrar = event.registrar("10");
registrar.playToServer(RenameNotebookPagePayload.TYPE, RenameNotebookPagePayload.STREAM_CODEC,
ModNetworking::renameNotebookPage);
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
ModNetworking::requestDescriptiveBookScreen);
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
@@ -70,6 +73,16 @@ public final class ModNetworking {
ClientAgeCelestialAngles::handle);
}
private static void renameNotebookPage(RenameNotebookPagePayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)
|| !(player.containerMenu instanceof NotebookMenu menu)
|| menu.containerId != payload.containerId()
|| payload.name().length() > MAX_BOOK_TITLE_LENGTH) {
return;
}
menu.renamePage(payload.slot(), normalizeTitle(payload.name()));
}
private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) {
return;
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.network;
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.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.ResourceLocation;
public record RenameNotebookPagePayload(int containerId, int slot, String name) implements CustomPacketPayload {
public static final Type<RenameNotebookPagePayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "rename_notebook_page"));
public static final StreamCodec<ByteBuf, RenameNotebookPagePayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, RenameNotebookPagePayload::containerId,
ByteBufCodecs.VAR_INT, RenameNotebookPagePayload::slot,
ByteBufCodecs.STRING_UTF8, RenameNotebookPagePayload::name,
RenameNotebookPagePayload::new);
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -19,6 +19,8 @@ public final class ModCreativeTabs {
.displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
output.accept(ModItems.LINKING_BOOK.get());
output.accept(ModItems.PAGE.get());
output.accept(ModItems.NOTEBOOK.get());
})
.build());
@@ -3,6 +3,9 @@ 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.NotebookContents;
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 +42,24 @@ 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());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<NotebookContents>> NOTEBOOK_CONTENTS =
DATA_COMPONENTS.register("notebook_contents", () -> DataComponentType.<NotebookContents>builder()
.persistent(NotebookContents.CODEC)
.networkSynchronized(NotebookContents.STREAM_CODEC)
.cacheEncoding()
.build());
private ModDataComponents() {
}
@@ -3,6 +3,8 @@ 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.dnorberg.rivencraft.item.NotebookItem;
import net.minecraft.world.item.Item;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredItem;
@@ -21,6 +23,16 @@ 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)
);
public static final DeferredItem<NotebookItem> NOTEBOOK = ITEMS.registerItem(
"notebook",
NotebookItem::new,
new Item.Properties().stacksTo(1)
);
private ModItems() {
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.inventory.MenuType;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.common.extensions.IMenuTypeExtension;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
public final class ModMenus {
private static final DeferredRegister<MenuType<?>> MENUS =
DeferredRegister.create(Registries.MENU, Rivencraft.MOD_ID);
public static final DeferredHolder<MenuType<?>, MenuType<NotebookMenu>> NOTEBOOK =
MENUS.register("notebook", () -> IMenuTypeExtension.create(NotebookMenu::new));
private ModMenus() {
}
public static void register(IEventBus modEventBus) {
MENUS.register(modEventBus);
}
}
@@ -0,0 +1,88 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.world.item.ItemStack;
/** A bounded, position-preserving collection of Page stacks stored on a Notebook item. */
public record NotebookContents(List<ItemStack> pages) {
public static final int MAX_PAGES = 27;
public static final NotebookContents EMPTY = new NotebookContents(List.of());
public static final Codec<NotebookContents> CODEC = ItemStack.OPTIONAL_CODEC.listOf()
.validate(NotebookContents::validate)
.xmap(NotebookContents::new, NotebookContents::pages);
public static final StreamCodec<RegistryFriendlyByteBuf, NotebookContents> STREAM_CODEC = StreamCodec.of(
NotebookContents::encode, NotebookContents::decode);
public NotebookContents {
if (pages.size() > MAX_PAGES) {
throw new IllegalArgumentException("Notebook exceeds " + MAX_PAGES + " page positions");
}
List<ItemStack> copies = new ArrayList<>(pages.size());
for (ItemStack page : pages) {
validatePage(page).getOrThrow();
copies.add(page.copy());
}
pages = List.copyOf(copies);
}
public int pageCount() {
return (int)pages.stream().filter(page -> !page.isEmpty()).count();
}
public int unresolvedCount() {
return (int)pages.stream()
.filter(page -> !page.isEmpty()
&& PageContent.Kind.UNRESOLVED_SYMBOL == PageItem.content(page).kind())
.count();
}
private static DataResult<List<ItemStack>> validate(List<ItemStack> pages) {
if (pages.size() > MAX_PAGES) {
return DataResult.error(() -> "Notebook exceeds " + MAX_PAGES + " page positions");
}
for (ItemStack page : pages) {
DataResult<ItemStack> result = validatePage(page);
if (result.error().isPresent()) {
return DataResult.error(() -> result.error().orElseThrow().message());
}
}
return DataResult.success(pages);
}
private static DataResult<ItemStack> validatePage(ItemStack page) {
if (page.isEmpty()) {
return DataResult.success(page);
}
if (!page.is(ModItems.PAGE.get())) {
return DataResult.error(() -> "Notebook contains a non-Page item");
}
return page.getCount() == 1
? DataResult.success(page)
: DataResult.error(() -> "Notebook Page stacks must contain exactly one item");
}
private static void encode(RegistryFriendlyByteBuf buffer, NotebookContents contents) {
ByteBufCodecs.VAR_INT.encode(buffer, contents.pages.size());
contents.pages.forEach(page -> ItemStack.OPTIONAL_STREAM_CODEC.encode(buffer, page));
}
private static NotebookContents decode(RegistryFriendlyByteBuf buffer) {
int size = ByteBufCodecs.VAR_INT.decode(buffer);
if (size < 0 || size > MAX_PAGES) {
throw new IllegalArgumentException("Invalid Notebook page count " + size);
}
List<ItemStack> pages = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
pages.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(buffer));
}
return new NotebookContents(pages);
}
}
@@ -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,20 @@
"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",
"item.rivencraft.notebook": "Notebook",
"item.rivencraft.notebook.pages": "Pages: %s/%s",
"item.rivencraft.notebook.unresolved": "Unresolved pages: %s",
"screen.rivencraft.notebook.rename": "Rename selected page",
"screen.rivencraft.notebook.search": "Search pages",
"screen.rivencraft.notebook.rename_toggle": "Rename selected page (R); use left/right arrows to reorder",
"screen.rivencraft.notebook.move_previous": "Move selected page left",
"screen.rivencraft.notebook.move_next": "Move selected page right",
"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 +71,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,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/notebook"
}
}
@@ -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: 303 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,21 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"key": {
"L": {
"item": "minecraft:leather"
},
"S": {
"item": "minecraft:string"
}
},
"pattern": [
" L ",
"S ",
" L "
],
"result": {
"count": 1,
"id": "rivencraft:notebook"
}
}
@@ -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,8 @@
"package": "net.dnorberg.rivencraft.mixin",
"compatibilityLevel": "JAVA_21",
"client": [
"client.ClientLevelSoundMixin",
"client.ImmersivePortalsGuiRenderingMixin",
"client.LevelRendererMixin"
],
"injectors": {