diff --git a/README.md b/README.md
index 41ef6ee..5647821 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,10 @@ 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 milestone is complete and books/linking is in progress. The
-project currently provides an unwritten Descriptive Book and a functional
-Linking Book with persistent, dimension-aware destinations and safe landing
-checks. Age generation, symbols, instability, and writing mechanics have not
-been ported yet.
+The bootstrap and books/linking milestones are complete. The project currently
+provides functional Linking Books and temporary vanilla-dimension Descriptive
+Book destinations with generated Return Links. Age generation, symbols,
+instability, and writing mechanics have not been ported yet.
## Linking Book
@@ -17,7 +16,7 @@ air. An unbound book opens a screen where it can be named and bound to the
current location. A bound book opens its destination screen and travels when
its preview panel is clicked. The destination is stored on the item and
survives inventory and world saves. Travel only succeeds if the target
-dimension is loaded and a solid, collision-free landing position can be found
+dimension is loaded and a non-fluid, passable landing position can be found
nearby.
Right-click an empty vanilla lectern with a Rivencraft book to convert it into
@@ -32,6 +31,13 @@ rotating Ponder scene on the left page, backed by the
destination biome's sky color. The snapshot is visual only; the server
independently validates every link.
+## Descriptive Book
+
+Until generated Ages arrive in Milestone 3, written Descriptive Books resolve
+deterministically to the Overworld, Nether, or End according to their Age UUID.
+The Rivencraft creative tab includes one sample book for each. Linking creates
+a bound Return Link to the exact source position and orientation.
+
## Requirements
- A Java 21 JDK
diff --git a/docs/PORTING.md b/docs/PORTING.md
index e4e9666..28cdc0b 100644
--- a/docs/PORTING.md
+++ b/docs/PORTING.md
@@ -10,7 +10,7 @@ split into independently testable milestones.
1. **Bootstrap** — NeoForge 1.21.1 build, mod metadata, item registration, and
CI. **Complete.**
2. **Books and linking** — descriptive/linking book data components, lectern-like
- interaction, safe teleportation, and return links. **In progress.**
+ interaction, safe teleportation, and return links. **Complete.**
3. **Ages** — dimension definitions backed by data packs, deterministic seeds,
lifecycle management, and persistence.
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly.
@@ -42,10 +42,10 @@ the symbol grammar is introduced.
- [x] Persistent, network-synchronized link-target data component.
- [x] Linking Book binding with dimension, block position, yaw, and pitch.
- [x] Server-authoritative cross-dimension travel with world-border, collision,
- fluid, and floor checks.
+ and fluid checks.
- [x] Nearby landing search and clear failure messages.
- [x] Descriptive Book identity and Age reference components.
-- [ ] Return-link creation as part of Descriptive Book travel.
+- [x] Temporary vanilla-dimension Descriptive Book travel with return-link creation.
- [x] Lectern-to-Book-Stand conversion, displayed book, activation, removal,
persistence, and drops.
- [x] Book-screen foundation with server-authoritative naming, binding, and
@@ -145,6 +145,10 @@ unchanged.
Version 0.1.21 treats air and leaf blocks as passable linking destinations;
links no longer require a solid floor beneath the player.
+Version 0.2.0 completes Milestone 2. Until Milestone 3 provides generated Age
+dimensions, Descriptive Book UUIDs deterministically resolve to vanilla
+dimensions and create a bound Return Link to the source on successful travel.
+
The preview code is separated behind a renderer interface and uses an isolated
Ponder level populated from server-authoritative destination snapshots.
diff --git a/gradle.properties b/gradle.properties
index 35088e8..8f33516 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -33,7 +33,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. See https://semver.org/
-mod_version=0.1.21
+mod_version=0.2.0
# 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.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
diff --git a/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java b/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java
new file mode 100644
index 0000000..0d44dfd
--- /dev/null
+++ b/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java
@@ -0,0 +1,52 @@
+package net.dnorberg.rivencraft.book;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import net.minecraft.core.GlobalPos;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.level.Level;
+
+/**
+ * Temporary Milestone 2 destination resolver.
+ *
+ *
Until Ages own generated dimensions, an Age UUID deterministically selects
+ * one of the three vanilla dimensions. Milestone 3 will replace this with the
+ * persistent Age-to-dimension mapping without changing book identities.
+ */
+public final class VanillaAgeResolver {
+ private static final List> VANILLA_DIMENSIONS = List.of(
+ Level.OVERWORLD,
+ Level.NETHER,
+ Level.END
+ );
+ private static final List DEMO_AGES = List.of(
+ new AgeReference(new UUID(0L, 0L), "Overworld Age"),
+ new AgeReference(new UUID(0L, 1L), "Nether Age"),
+ new AgeReference(new UUID(0L, 2L), "End Age")
+ );
+
+ private VanillaAgeResolver() {
+ }
+
+ public static Optional resolve(ServerPlayer player, AgeReference age) {
+ ResourceKey dimension = VANILLA_DIMENSIONS.get(Math.floorMod(age.id().hashCode(), VANILLA_DIMENSIONS.size()));
+ ServerLevel level = player.getServer().getLevel(dimension);
+ if (level == null) {
+ return Optional.empty();
+ }
+
+ return Optional.of(new LinkTarget(
+ GlobalPos.of(dimension, level.getSharedSpawnPos()),
+ level.getSharedSpawnAngle(),
+ 0.0F
+ ));
+ }
+
+ /** Written sample books exposed in the creative tab until the writing system exists. */
+ public static List demoAges() {
+ return DEMO_AGES;
+ }
+}
diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientBookInteractionHandler.java b/src/main/java/net/dnorberg/rivencraft/client/ClientBookInteractionHandler.java
index 56850c3..5e4bbaf 100644
--- a/src/main/java/net/dnorberg/rivencraft/client/ClientBookInteractionHandler.java
+++ b/src/main/java/net/dnorberg/rivencraft/client/ClientBookInteractionHandler.java
@@ -32,7 +32,7 @@ public final class ClientBookInteractionHandler {
if (linkingBook) {
Minecraft.getInstance().setScreen(new LinkingBookScreen(event.getHand(), event.getItemStack().copy()));
} else {
- Minecraft.getInstance().setScreen(new DescriptiveBookScreen(event.getItemStack().copy()));
+ Minecraft.getInstance().setScreen(new DescriptiveBookScreen(event.getHand(), event.getItemStack().copy()));
}
}
@@ -55,7 +55,7 @@ public final class ClientBookInteractionHandler {
if (book.is(ModItems.LINKING_BOOK.get())) {
Minecraft.getInstance().setScreen(new LinkingBookScreen(event.getPos(), book.copy()));
} else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) {
- Minecraft.getInstance().setScreen(new DescriptiveBookScreen(book.copy()));
+ Minecraft.getInstance().setScreen(new DescriptiveBookScreen(event.getPos(), book.copy()));
}
}
}
diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java
index 517e0cd..335aa7d 100644
--- a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java
+++ b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java
@@ -1,23 +1,43 @@
package net.dnorberg.rivencraft.client.screen;
import net.dnorberg.rivencraft.book.AgeReference;
+import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload;
import net.dnorberg.rivencraft.registry.ModDataComponents;
+import net.minecraft.core.BlockPos;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.InteractionHand;
+import net.neoforged.neoforge.network.PacketDistributor;
public final class DescriptiveBookScreen extends Screen {
private final AgeReference age;
+ private final InteractionHand hand;
+ private final BlockPos bookStandPos;
- public DescriptiveBookScreen(ItemStack book) {
+ public DescriptiveBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
+ this.hand = hand;
+ bookStandPos = null;
+ }
+
+ public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book) {
+ super(book.getHoverName());
+ age = book.get(ModDataComponents.AGE_REFERENCE.get());
+ hand = InteractionHand.MAIN_HAND;
+ this.bookStandPos = bookStandPos.immutable();
}
@Override
protected void init() {
+ if (age != null) {
+ addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.book.link"), button -> link())
+ .bounds(width / 2 - 75, height / 2 + 30, 150, 20)
+ .build());
+ }
addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> onClose())
.bounds(width / 2 - 75, height / 2 + 55, 150, 20)
.build());
@@ -36,4 +56,13 @@ public final class DescriptiveBookScreen extends Screen {
graphics.drawCenteredString(font, Component.literal(age.id().toString()), centerX, height / 2 + 4, 0xFF777777);
}
}
+
+ private void link() {
+ PacketDistributor.sendToServer(new UseDescriptiveBookPayload(
+ bookStandPos != null,
+ bookStandPos == null && hand == InteractionHand.OFF_HAND,
+ bookStandPos == null ? BlockPos.ZERO : bookStandPos
+ ));
+ onClose();
+ }
}
diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java
index 5dc6033..b9a705d 100644
--- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java
+++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkingService;
+import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
@@ -44,6 +45,23 @@ public final class LinkingGameTests {
helper.succeed();
}
+ @GameTest(template = "empty")
+ public static void returnBookRetainsSourceLocation(GameTestHelper helper) {
+ LinkTarget source = new LinkTarget(
+ GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(ORIGIN)),
+ 45.0F,
+ -12.0F
+ );
+
+ ItemStack returnBook = DescriptiveBookItem.createReturnBook(source);
+
+ helper.assertTrue(returnBook.is(ModItems.LINKING_BOOK.get()),
+ "Descriptive Book travel must create a Linking Book");
+ helper.assertValueEqual(returnBook.get(ModDataComponents.LINK_TARGET.get()), source,
+ "The Return Link must retain the source dimension, location, and orientation");
+ helper.succeed();
+ }
+
@GameTest(template = "empty")
public static void travelUsesLeafyDestinationWithoutFloor(GameTestHelper helper) {
helper.setBlock(ORIGIN.below(), Blocks.AIR);
diff --git a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java
index 38bc059..203c15c 100644
--- a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java
+++ b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java
@@ -2,10 +2,18 @@ package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.book.AgeReference;
+import net.dnorberg.rivencraft.book.LinkTarget;
+import net.dnorberg.rivencraft.book.LinkingService;
+import net.dnorberg.rivencraft.book.VanillaAgeResolver;
import net.dnorberg.rivencraft.registry.ModDataComponents;
+import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.ChatFormatting;
+import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.sounds.SoundEvents;
+import net.minecraft.sounds.SoundSource;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
@@ -27,6 +35,42 @@ public final class DescriptiveBookItem extends Item {
return stack.has(ModDataComponents.AGE_REFERENCE.get()) || super.isFoil(stack);
}
+ public static boolean useWrittenBook(ServerPlayer player, ItemStack stack) {
+ AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
+ if (age == null) {
+ player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unwritten"), true);
+ return false;
+ }
+
+ LinkTarget returnTarget = LinkTarget.from(player);
+ LinkTarget ageTarget = VanillaAgeResolver.resolve(player, age).orElse(null);
+ if (ageTarget == null) {
+ player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
+ return false;
+ }
+
+ LinkingService.LinkResult result = LinkingService.travel(player, ageTarget);
+ if (result != LinkingService.LinkResult.SUCCESS) {
+ player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unsafe"), true);
+ return false;
+ }
+
+ ItemStack returnBook = createReturnBook(returnTarget);
+ if (!player.getInventory().add(returnBook)) {
+ player.drop(returnBook, false);
+ }
+ player.level().playSound(null, player.blockPosition(), SoundEvents.PORTAL_TRAVEL, SoundSource.PLAYERS, 0.35F, 1.0F);
+ player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.linked", age.name()), true);
+ return true;
+ }
+
+ public static ItemStack createReturnBook(LinkTarget returnTarget) {
+ ItemStack returnBook = new ItemStack(ModItems.LINKING_BOOK.get());
+ returnBook.set(ModDataComponents.LINK_TARGET.get(), returnTarget);
+ returnBook.set(DataComponents.CUSTOM_NAME, Component.translatable("item.rivencraft.linking_book.return"));
+ return returnBook;
+ }
+
@Override
public void appendHoverText(
ItemStack stack,
diff --git a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java
index 2a9c162..9e7013e 100644
--- a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java
+++ b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler;
+import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
@@ -30,9 +31,11 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
- PayloadRegistrar registrar = event.registrar("3");
+ PayloadRegistrar registrar = event.registrar("4");
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, ModNetworking::bindBook);
registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC, ModNetworking::useBook);
+ registrar.playToServer(UseDescriptiveBookPayload.TYPE, UseDescriptiveBookPayload.STREAM_CODEC,
+ ModNetworking::useDescriptiveBook);
registrar.playToServer(UseBookStandPayload.TYPE, UseBookStandPayload.STREAM_CODEC, ModNetworking::useBookStand);
registrar.playToServer(RequestLinkPreviewPayload.TYPE, RequestLinkPreviewPayload.STREAM_CODEC,
ModNetworking::requestLinkPreview);
@@ -85,6 +88,27 @@ public final class ModNetworking {
}
}
+ private static void useDescriptiveBook(UseDescriptiveBookPayload payload, IPayloadContext context) {
+ if (!(context.player() instanceof ServerPlayer player)) {
+ return;
+ }
+
+ ItemStack stack;
+ if (payload.fromStand()) {
+ if (player.distanceToSqr(Vec3.atCenterOf(payload.standPos())) > 64.0D
+ || !(player.level().getBlockEntity(payload.standPos()) instanceof BookStandBlockEntity stand)) {
+ return;
+ }
+ stack = stand.getBook();
+ } else {
+ stack = player.getItemInHand(hand(payload.offhand()));
+ }
+
+ if (stack.is(ModItems.DESCRIPTIVE_BOOK.get())) {
+ DescriptiveBookItem.useWrittenBook(player, stack);
+ }
+ }
+
private static void requestLinkPreview(RequestLinkPreviewPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) {
return;
diff --git a/src/main/java/net/dnorberg/rivencraft/network/UseDescriptiveBookPayload.java b/src/main/java/net/dnorberg/rivencraft/network/UseDescriptiveBookPayload.java
new file mode 100644
index 0000000..b447baf
--- /dev/null
+++ b/src/main/java/net/dnorberg/rivencraft/network/UseDescriptiveBookPayload.java
@@ -0,0 +1,28 @@
+package net.dnorberg.rivencraft.network;
+
+import net.dnorberg.rivencraft.Rivencraft;
+import net.minecraft.core.BlockPos;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+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;
+
+/** Requests a server-validated Descriptive Book link from a hand or Book Stand. */
+public record UseDescriptiveBookPayload(boolean fromStand, boolean offhand, BlockPos standPos)
+ implements CustomPacketPayload {
+ public static final Type TYPE = new Type<>(
+ ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_descriptive_book")
+ );
+ public static final StreamCodec STREAM_CODEC = StreamCodec.composite(
+ ByteBufCodecs.BOOL, UseDescriptiveBookPayload::fromStand,
+ ByteBufCodecs.BOOL, UseDescriptiveBookPayload::offhand,
+ BlockPos.STREAM_CODEC, UseDescriptiveBookPayload::standPos,
+ UseDescriptiveBookPayload::new
+ );
+
+ @Override
+ public Type extends CustomPacketPayload> type() {
+ return TYPE;
+ }
+}
diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java
index 91d4902..281ad39 100644
--- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java
+++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java
@@ -1,9 +1,12 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
+import net.dnorberg.rivencraft.book.VanillaAgeResolver;
+import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab;
+import net.minecraft.world.item.ItemStack;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
@@ -18,6 +21,12 @@ public final class ModCreativeTabs {
.icon(() -> ModItems.DESCRIPTIVE_BOOK.get().getDefaultInstance())
.displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
+ VanillaAgeResolver.demoAges().forEach(age -> {
+ ItemStack book = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
+ book.set(ModDataComponents.AGE_REFERENCE.get(), age);
+ book.set(DataComponents.CUSTOM_NAME, Component.literal(age.name()));
+ output.accept(book);
+ });
output.accept(ModItems.LINKING_BOOK.get());
})
.build());
diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json
index a8153f3..178db7e 100644
--- a/src/main/resources/assets/rivencraft/lang/en_us.json
+++ b/src/main/resources/assets/rivencraft/lang/en_us.json
@@ -5,12 +5,17 @@
"item.rivencraft.linking_book": "Linking Book",
"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",
"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",
"message.rivencraft.linking_book.linked": "Linked",
"message.rivencraft.linking_book.missing_dimension": "That dimension is unavailable",
"message.rivencraft.linking_book.unsafe": "No safe destination was found near the link",
+ "message.rivencraft.descriptive_book.unwritten": "This Descriptive Book is unwritten",
+ "message.rivencraft.descriptive_book.unavailable": "That Age is unavailable",
+ "message.rivencraft.descriptive_book.unsafe": "No safe destination was found in that Age",
+ "message.rivencraft.descriptive_book.linked": "Linked to %s — a Return Link was created",
"screen.rivencraft.book.title": "Book title",
"screen.rivencraft.book.title_hint": "Name this Linking Book",
"screen.rivencraft.book.name_prompt": "Name the book before binding it to this location",