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 [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
1.21.1 on NeoForge. 1.21.1 on NeoForge.
The bootstrap milestone is complete and books/linking is in progress. The The bootstrap and books/linking milestones are complete. The project currently
project currently provides an unwritten Descriptive Book and a functional provides functional Linking Books and temporary vanilla-dimension Descriptive
Linking Book with persistent, dimension-aware destinations and safe landing Book destinations with generated Return Links. Age generation, symbols,
checks. Age generation, symbols, instability, and writing mechanics have not instability, and writing mechanics have not been ported yet.
been ported yet.
## Linking Book ## 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 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 its preview panel is clicked. The destination is stored on the item and
survives inventory and world saves. Travel only succeeds if the target 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. nearby.
Right-click an empty vanilla lectern with a Rivencraft book to convert it into 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 destination biome's sky color. The snapshot is visual only; the server
independently validates every link. 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 ## Requirements
- A Java 21 JDK - 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 1. **Bootstrap** — NeoForge 1.21.1 build, mod metadata, item registration, and
CI. **Complete.** CI. **Complete.**
2. **Books and linking** — descriptive/linking book data components, lectern-like 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, 3. **Ages** — dimension definitions backed by data packs, deterministic seeds,
lifecycle management, and persistence. lifecycle management, and persistence.
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly. 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] Persistent, network-synchronized link-target data component.
- [x] Linking Book binding with dimension, block position, yaw, and pitch. - [x] Linking Book binding with dimension, block position, yaw, and pitch.
- [x] Server-authoritative cross-dimension travel with world-border, collision, - [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] Nearby landing search and clear failure messages.
- [x] Descriptive Book identity and Age reference components. - [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, - [x] Lectern-to-Book-Stand conversion, displayed book, activation, removal,
persistence, and drops. persistence, and drops.
- [x] Book-screen foundation with server-authoritative naming, binding, and - [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; Version 0.1.21 treats air and leaf blocks as passable linking destinations;
links no longer require a solid floor beneath the player. 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 The preview code is separated behind a renderer interface and uses an isolated
Ponder level populated from server-authoritative destination snapshots. 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. # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later mod_license=LGPL-3.0-or-later
# The mod version. See https://semver.org/ # 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. # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources. # This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html # 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) { if (linkingBook) {
Minecraft.getInstance().setScreen(new LinkingBookScreen(event.getHand(), event.getItemStack().copy())); Minecraft.getInstance().setScreen(new LinkingBookScreen(event.getHand(), event.getItemStack().copy()));
} else { } 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())) { if (book.is(ModItems.LINKING_BOOK.get())) {
Minecraft.getInstance().setScreen(new LinkingBookScreen(event.getPos(), book.copy())); Minecraft.getInstance().setScreen(new LinkingBookScreen(event.getPos(), book.copy()));
} else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) { } 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; package net.dnorberg.rivencraft.client.screen;
import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.minecraft.core.BlockPos;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.InteractionHand;
import net.neoforged.neoforge.network.PacketDistributor;
public final class DescriptiveBookScreen extends Screen { public final class DescriptiveBookScreen extends Screen {
private final AgeReference age; 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()); super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get()); 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 @Override
protected void init() { 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()) addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> onClose())
.bounds(width / 2 - 75, height / 2 + 55, 150, 20) .bounds(width / 2 - 75, height / 2 + 55, 150, 20)
.build()); .build());
@@ -36,4 +56,13 @@ public final class DescriptiveBookScreen extends Screen {
graphics.drawCenteredString(font, Component.literal(age.id().toString()), centerX, height / 2 + 4, 0xFF777777); 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.Rivencraft;
import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkingService; import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
@@ -44,6 +45,23 @@ public final class LinkingGameTests {
helper.succeed(); 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") @GameTest(template = "empty")
public static void travelUsesLeafyDestinationWithoutFloor(GameTestHelper helper) { public static void travelUsesLeafyDestinationWithoutFloor(GameTestHelper helper) {
helper.setBlock(ORIGIN.below(), Blocks.AIR); helper.setBlock(ORIGIN.below(), Blocks.AIR);
@@ -2,10 +2,18 @@ package net.dnorberg.rivencraft.item;
import java.util.List; import java.util.List;
import net.dnorberg.rivencraft.book.AgeReference; 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.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent; 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.Item;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag; 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); 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 @Override
public void appendHoverText( public void appendHoverText(
ItemStack stack, ItemStack stack,
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler; import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
@@ -30,9 +31,11 @@ public final class ModNetworking {
} }
public static void register(RegisterPayloadHandlersEvent event) { 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(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, ModNetworking::bindBook);
registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC, ModNetworking::useBook); 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(UseBookStandPayload.TYPE, UseBookStandPayload.STREAM_CODEC, ModNetworking::useBookStand);
registrar.playToServer(RequestLinkPreviewPayload.TYPE, RequestLinkPreviewPayload.STREAM_CODEC, registrar.playToServer(RequestLinkPreviewPayload.TYPE, RequestLinkPreviewPayload.STREAM_CODEC,
ModNetworking::requestLinkPreview); 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) { private static void requestLinkPreview(RequestLinkPreviewPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) { if (!(context.player() instanceof ServerPlayer player)) {
return; 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; package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft; 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.core.registries.Registries;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.ItemStack;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredHolder; import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister; import net.neoforged.neoforge.registries.DeferredRegister;
@@ -18,6 +21,12 @@ public final class ModCreativeTabs {
.icon(() -> ModItems.DESCRIPTIVE_BOOK.get().getDefaultInstance()) .icon(() -> ModItems.DESCRIPTIVE_BOOK.get().getDefaultInstance())
.displayItems((parameters, output) -> { .displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get()); 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()); output.accept(ModItems.LINKING_BOOK.get());
}) })
.build()); .build());
@@ -5,12 +5,17 @@
"item.rivencraft.linking_book": "Linking Book", "item.rivencraft.linking_book": "Linking Book",
"item.rivencraft.linking_book.unbound": "Unbound — sneak-use to bind", "item.rivencraft.linking_book.unbound": "Unbound — sneak-use to bind",
"item.rivencraft.linking_book.destination": "%s (%s, %s, %s)", "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.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.unbound": "This Linking Book is unbound",
"message.rivencraft.linking_book.bound": "Linking Book bound as %s", "message.rivencraft.linking_book.bound": "Linking Book bound as %s",
"message.rivencraft.linking_book.linked": "Linked", "message.rivencraft.linking_book.linked": "Linked",
"message.rivencraft.linking_book.missing_dimension": "That dimension is unavailable", "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.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": "Book title",
"screen.rivencraft.book.title_hint": "Name this Linking Book", "screen.rivencraft.book.title_hint": "Name this Linking Book",
"screen.rivencraft.book.name_prompt": "Name the book before binding it to this location", "screen.rivencraft.book.name_prompt": "Name the book before binding it to this location",