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 [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. The project currently provides a valid The bootstrap milestone is complete and books/linking is in progress. The
NeoForge mod, a creative tab, and an unwritten Descriptive Book item. Age project currently provides an unwritten Descriptive Book and a functional
generation, linking, symbols, instability, and writing mechanics have not been Linking Book with persistent, dimension-aware destinations and safe landing
ported yet. 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 ## 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 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. interaction, safe teleportation, and return links. **In progress.**
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.
@@ -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 return. This exercises persistence, networking, dimensions, and safety before
the symbol grammar is introduced. 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 ## Milestone 1 acceptance record
- The project builds on a Java 21 JDK with `./gradlew build`. - 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 com.mojang.logging.LogUtils;
import net.dnorberg.rivencraft.registry.ModCreativeTabs; import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod; import net.neoforged.fml.common.Mod;
@@ -13,6 +14,7 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger(); public static final Logger LOGGER = LogUtils.getLogger();
public Rivencraft(IEventBus modEventBus) { public Rivencraft(IEventBus modEventBus) {
ModDataComponents.register(modEventBus);
ModItems.register(modEventBus); ModItems.register(modEventBus);
ModCreativeTabs.register(modEventBus); ModCreativeTabs.register(modEventBus);
LOGGER.info("Initializing Rivencraft"); 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() CREATIVE_TABS.register("rivencraft", () -> CreativeModeTab.builder()
.title(Component.translatable("itemGroup.rivencraft")) .title(Component.translatable("itemGroup.rivencraft"))
.icon(() -> ModItems.DESCRIPTIVE_BOOK.get().getDefaultInstance()) .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()); .build());
private ModCreativeTabs() { 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.Rivencraft;
import net.dnorberg.rivencraft.item.DescriptiveBookItem; import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.minecraft.world.item.Item; import net.minecraft.world.item.Item;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredItem; import net.neoforged.neoforge.registries.DeferredItem;
@@ -15,6 +16,11 @@ public final class ModItems {
DescriptiveBookItem::new, DescriptiveBookItem::new,
new Item.Properties().stacksTo(1) 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() { private ModItems() {
} }
@@ -1,5 +1,13 @@
{ {
"item.rivencraft.descriptive_book": "Descriptive Book", "item.rivencraft.descriptive_book": "Descriptive Book",
"item.rivencraft.descriptive_book.unwritten": "Unwritten", "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" "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"
}
}