Add persistent linking books
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-21 06:42:06 -04:00
1 parent db768bb4f7
commit 55c581bf01
12 files changed
+317 -6

No files matched your search

+13 -4
View File
@@ -4,10 +4,19 @@ 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. The project currently provides a valid
NeoForge mod, a creative tab, and an unwritten Descriptive Book item. Age
generation, linking, symbols, instability, and writing mechanics have not been
ported yet.
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.
## Linking Book
Craft a Linking Book from a vanilla book and an ender pearl. Sneak-right-click
the unbound book to record the current location, then right-click it to return
there. 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 nearby.
## Requirements
+17 -1
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.
interaction, safe teleportation, and return links. **In progress.**
3. **Ages** — dimension definitions backed by data packs, deterministic seeds,
lifecycle management, and persistence.
4. **Writing system** — pages, symbols, notebooks, desks, and book assembly.
@@ -37,6 +37,22 @@ server-managed Age definition, travel to that Age, and use a Linking Book to
return. This exercises persistence, networking, dimensions, and safety before
the symbol grammar is introduced.
### Milestone 2 progress
- [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.
- [x] Nearby landing search and clear failure messages.
- [ ] Descriptive Book identity and Age reference components.
- [ ] Return-link creation as part of Descriptive Book travel.
- [ ] Lectern/book-stand interaction.
- [ ] Automated in-game tests for binding, persistence, and travel.
The temporary shapeless Linking Book recipe makes the linking subsystem easy
to test. It can move into the writing/bookbinder progression when that system
is implemented.
## Milestone 1 acceptance record
- The project builds on a Java 21 JDK with `./gradlew build`.
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft;
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.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
@@ -13,6 +14,7 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger();
public Rivencraft(IEventBus modEventBus) {
ModDataComponents.register(modEventBus);
ModItems.register(modEventBus);
ModCreativeTabs.register(modEventBus);
LOGGER.info("Initializing Rivencraft");
@@ -0,0 +1,36 @@
package net.dnorberg.rivencraft.book;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import net.minecraft.core.GlobalPos;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.server.level.ServerPlayer;
/** A persistent, dimension-aware destination recorded in a Linking Book. */
public record LinkTarget(GlobalPos location, float yaw, float pitch) {
public static final Codec<LinkTarget> CODEC = RecordCodecBuilder.create(instance -> instance.group(
GlobalPos.CODEC.fieldOf("location").forGetter(LinkTarget::location),
Codec.FLOAT.fieldOf("yaw").forGetter(LinkTarget::yaw),
Codec.FLOAT.fieldOf("pitch").forGetter(LinkTarget::pitch)
).apply(instance, LinkTarget::new));
public static final StreamCodec<ByteBuf, LinkTarget> STREAM_CODEC = StreamCodec.composite(
GlobalPos.STREAM_CODEC,
LinkTarget::location,
ByteBufCodecs.FLOAT,
LinkTarget::yaw,
ByteBufCodecs.FLOAT,
LinkTarget::pitch,
LinkTarget::new
);
public static LinkTarget from(ServerPlayer player) {
return new LinkTarget(
GlobalPos.of(player.level().dimension(), player.blockPosition()),
player.getYRot(),
player.getXRot()
);
}
}
@@ -0,0 +1,79 @@
package net.dnorberg.rivencraft.book;
import java.util.Comparator;
import java.util.Optional;
import java.util.stream.Stream;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.border.WorldBorder;
import net.minecraft.world.phys.Vec3;
/** Server-side validation and execution for book travel. */
public final class LinkingService {
private static final int HORIZONTAL_SEARCH_RADIUS = 4;
private static final int VERTICAL_SEARCH_RADIUS = 2;
private LinkingService() {
}
public static LinkResult travel(ServerPlayer player, LinkTarget target) {
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
if (destinationLevel == null) {
return LinkResult.MISSING_DIMENSION;
}
Optional<Vec3> destination = findSafeDestination(destinationLevel, target.location().pos());
if (destination.isEmpty()) {
return LinkResult.NO_SAFE_DESTINATION;
}
Vec3 position = destination.get();
player.teleportTo(
destinationLevel,
position.x,
position.y,
position.z,
target.yaw(),
target.pitch()
);
player.resetFallDistance();
return LinkResult.SUCCESS;
}
static Optional<Vec3> findSafeDestination(ServerLevel level, BlockPos origin) {
WorldBorder border = level.getWorldBorder();
return candidates(origin)
.filter(level::isInWorldBounds)
.filter(border::isWithinBounds)
.filter(position -> isSafe(level, position))
.map(position -> Vec3.atBottomCenterOf(position))
.findFirst();
}
private static Stream<BlockPos> candidates(BlockPos origin) {
return BlockPos.betweenClosedStream(
origin.offset(-HORIZONTAL_SEARCH_RADIUS, -VERTICAL_SEARCH_RADIUS, -HORIZONTAL_SEARCH_RADIUS),
origin.offset(HORIZONTAL_SEARCH_RADIUS, VERTICAL_SEARCH_RADIUS, HORIZONTAL_SEARCH_RADIUS)
)
.map(BlockPos::immutable)
.sorted(Comparator.comparingDouble(position -> position.distSqr(origin)));
}
private static boolean isSafe(ServerLevel level, BlockPos feet) {
BlockPos head = feet.above();
BlockPos floor = feet.below();
return level.getBlockState(feet).getCollisionShape(level, feet).isEmpty()
&& level.getFluidState(feet).isEmpty()
&& level.getBlockState(head).getCollisionShape(level, head).isEmpty()
&& level.getFluidState(head).isEmpty()
&& level.getBlockState(floor).isFaceSturdy(level, floor, Direction.UP);
}
public enum LinkResult {
SUCCESS,
MISSING_DIMENSION,
NO_SAFE_DESTINATION
}
}
@@ -0,0 +1,102 @@
package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResultHolder;
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 LinkingBookItem extends Item {
private static final int LINK_COOLDOWN_TICKS = 40;
public LinkingBookItem(Properties properties) {
super(properties);
}
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand usedHand) {
ItemStack stack = player.getItemInHand(usedHand);
if (!(player instanceof ServerPlayer serverPlayer)) {
return InteractionResultHolder.sidedSuccess(stack, level.isClientSide());
}
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
if (target == null) {
if (!player.isShiftKeyDown()) {
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.bind_hint"), true);
return InteractionResultHolder.fail(stack);
}
target = LinkTarget.from(serverPlayer);
stack.set(ModDataComponents.LINK_TARGET.get(), target);
level.playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F);
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.bound"), true);
return InteractionResultHolder.success(stack);
}
LinkingService.LinkResult result = LinkingService.travel(serverPlayer, target);
if (result == LinkingService.LinkResult.SUCCESS) {
serverPlayer.getCooldowns().addCooldown(this, LINK_COOLDOWN_TICKS);
serverPlayer.level().playSound(
null,
serverPlayer.blockPosition(),
SoundEvents.PORTAL_TRAVEL,
SoundSource.PLAYERS,
0.35F,
1.0F
);
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.linked"), true);
return InteractionResultHolder.success(stack);
}
String errorKey = result == LinkingService.LinkResult.MISSING_DIMENSION
? "message.rivencraft.linking_book.missing_dimension"
: "message.rivencraft.linking_book.unsafe";
player.displayClientMessage(Component.translatable(errorKey), true);
return InteractionResultHolder.fail(stack);
}
@Override
public boolean isFoil(ItemStack stack) {
return stack.has(ModDataComponents.LINK_TARGET.get()) || super.isFoil(stack);
}
@Override
public void appendHoverText(
ItemStack stack,
TooltipContext context,
List<Component> tooltipComponents,
TooltipFlag tooltipFlag
) {
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
if (target == null) {
tooltipComponents.add(Component.translatable("item.rivencraft.linking_book.unbound")
.withStyle(ChatFormatting.GRAY));
return;
}
ResourceLocation dimension = target.location().dimension().location();
BlockPos position = target.location().pos();
tooltipComponents.add(Component.translatable(
"item.rivencraft.linking_book.destination",
dimension.toString(),
position.getX(),
position.getY(),
position.getZ()
)
.withStyle(ChatFormatting.GRAY));
}
}
@@ -16,7 +16,10 @@ public final class ModCreativeTabs {
CREATIVE_TABS.register("rivencraft", () -> CreativeModeTab.builder()
.title(Component.translatable("itemGroup.rivencraft"))
.icon(() -> ModItems.DESCRIPTIVE_BOOK.get().getDefaultInstance())
.displayItems((parameters, output) -> output.accept(ModItems.DESCRIPTIVE_BOOK.get()))
.displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
output.accept(ModItems.LINKING_BOOK.get());
})
.build());
private ModCreativeTabs() {
@@ -0,0 +1,28 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
public final class ModDataComponents {
private static final DeferredRegister<DataComponentType<?>> DATA_COMPONENTS =
DeferredRegister.create(Registries.DATA_COMPONENT_TYPE, Rivencraft.MOD_ID);
public static final DeferredHolder<DataComponentType<?>, DataComponentType<LinkTarget>> LINK_TARGET =
DATA_COMPONENTS.register("link_target", () -> DataComponentType.<LinkTarget>builder()
.persistent(LinkTarget.CODEC)
.networkSynchronized(LinkTarget.STREAM_CODEC)
.cacheEncoding()
.build());
private ModDataComponents() {
}
public static void register(IEventBus modEventBus) {
DATA_COMPONENTS.register(modEventBus);
}
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.minecraft.world.item.Item;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredItem;
@@ -15,6 +16,11 @@ public final class ModItems {
DescriptiveBookItem::new,
new Item.Properties().stacksTo(1)
);
public static final DeferredItem<LinkingBookItem> LINKING_BOOK = ITEMS.registerItem(
"linking_book",
LinkingBookItem::new,
new Item.Properties().stacksTo(1)
);
private ModItems() {
}
@@ -1,5 +1,13 @@
{
"item.rivencraft.descriptive_book": "Descriptive Book",
"item.rivencraft.descriptive_book.unwritten": "Unwritten",
"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)",
"message.rivencraft.linking_book.bind_hint": "Sneak-use to bind this book to your current location",
"message.rivencraft.linking_book.bound": "Linking Book bound",
"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",
"itemGroup.rivencraft": "Rivencraft"
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "minecraft:item/enchanted_book"
}
}
@@ -0,0 +1,16 @@
{
"type": "minecraft:crafting_shapeless",
"category": "misc",
"ingredients": [
{
"item": "minecraft:book"
},
{
"item": "minecraft:ender_pearl"
}
],
"result": {
"count": 1,
"id": "rivencraft:linking_book"
}
}