Complete descriptive book return links
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-21 09:14:13 -04:00
1 parent aed8d6a5c3
commit 3e8cbf6bc5
12 files changed
+233 -14

No files matched your search

+12 -6
View File
@@ -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
+7 -3
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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.
*
* <p>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.</p>
*/
public final class VanillaAgeResolver {
private static final List<ResourceKey<Level>> VANILLA_DIMENSIONS = List.of(
Level.OVERWORLD,
Level.NETHER,
Level.END
);
private static final List<AgeReference> 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<LinkTarget> resolve(ServerPlayer player, AgeReference age) {
ResourceKey<Level> 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<AgeReference> demoAges() {
return DEMO_AGES;
}
}
@@ -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()));
}
}
}
@@ -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();
}
}
@@ -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);
@@ -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,
@@ -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;
@@ -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<UseDescriptiveBookPayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_descriptive_book")
);
public static final StreamCodec<RegistryFriendlyByteBuf, UseDescriptiveBookPayload> 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;
}
}
@@ -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());
@@ -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",