From 9ff931afa9f11b0dba6c2a069b575c3ea787456f Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Thu, 29 Oct 2026 14:32:50 -0400 Subject: [PATCH] Add 26.3 descriptive book binding and travel --- docs/MINECRAFT_26_3_ALPHA_2.md | 37 ++++++ docs/MINECRAFT_26_3_PORT.md | 7 ++ ports/mc263/README.md | 6 +- ports/mc263/gradle.properties | 2 +- .../rivencraft/age/RuntimeAgeBackend.java | 7 ++ .../client/PortBookInteractionHandler.java | 11 +- .../client/PortDescriptiveBookScreen.java | 116 ++++++++++++++++++ .../rivencraft/item/DescriptiveBookItem.java | 18 +-- .../network/BindDescriptiveBookPayload.java | 22 ++++ .../network/PortBookNetworking.java | 51 ++++++++ .../network/UseDescriptiveBookPayload.java | 21 ++++ 11 files changed, 276 insertions(+), 22 deletions(-) create mode 100644 docs/MINECRAFT_26_3_ALPHA_2.md create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BindDescriptiveBookPayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseDescriptiveBookPayload.java diff --git a/docs/MINECRAFT_26_3_ALPHA_2.md b/docs/MINECRAFT_26_3_ALPHA_2.md new file mode 100644 index 0000000..3a6b662 --- /dev/null +++ b/docs/MINECRAFT_26_3_ALPHA_2.md @@ -0,0 +1,37 @@ +# Rivencraft 1.0.1.3-alpha.1 — Minecraft 26.3 + +Experimental **NeoForge 26.3** port preview. This is separate from the +supported Minecraft 1.21.1 release and does **not** have feature parity. Use +a disposable test world or make a backup first. Do not migrate a 1.21.1 +Rivencraft world to this alpha. + +## Changes since 1.0.1.2-alpha.1 + +- An unbound Descriptive Book now opens a naming screen. Binding it creates a + distinct prototype Age with a random seed; duplicate names receive a + numbered suffix. +- A bound Descriptive Book opens a book screen whose destination panel can be + clicked to enter its Age. The server validates the held book and waits for + the client's Age catalog acknowledgement before travel. +- Retired Age IDs are excluded when assigning a new book's Age name, so an + archived world cannot be silently reused. + +## Still experimental + +These Ages use prototype vanilla terrain. Page-authored generation, crafting +and workstations, most items and blocks, portals, Link modifiers, instability, +live destination previews, and optional mod integrations are not ported. The +book panel is a placeholder, not a rendered destination view. The +[port status](MINECRAFT_26_3_PORT.md) and [open port issues](https://git.paddedcell.org/dnorberg/rivencraft/issues) +track the remaining work. The stable 1.21.1 release remains available +separately. + +## Verification + +- Clean 26.3 Gradle build passed. +- Disposable dedicated-server smoke test passed: two distinct Age seeds and + their terrain persisted across restart. +- The player verified Descriptive Book binding and travel in game on + 2026-10-29. + +These checks are **not** the full port acceptance matrix or a stability claim. diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md index 5bcf615..938361e 100644 --- a/docs/MINECRAFT_26_3_PORT.md +++ b/docs/MINECRAFT_26_3_PORT.md @@ -374,6 +374,13 @@ prototype vanilla terrain, normal authoring and most content are absent, and worlds should be disposable or backed up. The 1.21.1 stable release remains independent; issues #96–#104 stay open until the full port and validation pass. +The next development slice, `1.0.1.3-alpha.1`, adds a basic Descriptive Book +screen and server-validated binding. Naming an unbound book creates a unique +prototype Age with a random seed; clicking the bound book's placeholder panel +uses the acknowledged-catalog travel gate. This is not authored Page binding +or a live destination preview. The player confirmed the Descriptive Book +binding and travel interaction in game on 2026-10-29. + ## Verification of this first slice The 1.21.1 `compileJava` and `build` tasks passed offline after the backend diff --git a/ports/mc263/README.md b/ports/mc263/README.md index b5fe283..05ca3bc 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -3,8 +3,10 @@ This is an **experimental alpha port** for issues #97–#104, not gameplay parity with the Minecraft 1.21.1 release. It registers basic items and a runtime Age prototype with persisted seeds. The operator-only `rivencraft_port age` -commands create and inspect separate vanilla-terrain levels. Descriptive Book -travel and named Linking Book binding/travel work as limited probes. Authored +commands create and inspect separate vanilla-terrain levels. The current +development slice can name an unbound Descriptive Book to create a prototype +Age, then enter it from the book GUI; the player verified this interaction in game. +Named Linking Book binding/travel works as a limited probe. Authored generation, most content, recipes, workstations, portal systems, modifiers and the destination preview renderer remain unported. Use a test world and keep a backup; do not substitute this JAR for the 1.21.1 mod or import 1.21.1 saves. diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index b2c3b8b..e25dbb6 100644 --- a/ports/mc263/gradle.properties +++ b/ports/mc263/gradle.properties @@ -7,4 +7,4 @@ neo_version=26.3.0.99 mod_id=rivencraft mod_name=Rivencraft mod_license=LGPL-3.0-or-later -mod_version=1.0.1.2-alpha.1 +mod_version=1.0.1.3-alpha.1 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java index 318f048..e55fafc 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java @@ -153,6 +153,13 @@ public final class RuntimeAgeBackend { return index(server).entries(); } + /** Includes retired IDs, which must never be silently reused by a new book. */ + public static boolean isNameTaken(MinecraftServer server, Identifier id) { + checkServerThread(server); + ageName(id); + return index(server).contains(id); + } + private static AgeLevelIndex index(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(AgeLevelIndex.TYPE); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java index bb5e4da..62d2196 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookInteractionHandler.java @@ -16,11 +16,14 @@ public final class PortBookInteractionHandler { @SubscribeEvent public static void onRightClickItem(PlayerInteractEvent.RightClickItem event) { - if (!event.getEntity().level().isClientSide() - || !event.getItemStack().is(Rivencraft.LINKING_BOOK.get())) return; + if (!event.getEntity().level().isClientSide()) return; + boolean linking = event.getItemStack().is(Rivencraft.LINKING_BOOK.get()); + boolean descriptive = event.getItemStack().is(Rivencraft.DESCRIPTIVE_BOOK.get()); + if (!linking && !descriptive) return; event.setCanceled(true); event.setCancellationResult(InteractionResult.SUCCESS); - Minecraft.getInstance().setScreenAndShow(new PortLinkingBookScreen( - event.getHand(), event.getItemStack().copy())); + Minecraft.getInstance().setScreenAndShow(linking + ? new PortLinkingBookScreen(event.getHand(), event.getItemStack().copy()) + : new PortDescriptiveBookScreen(event.getHand(), event.getItemStack().copy())); } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java new file mode 100644 index 0000000..22d7069 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortDescriptiveBookScreen.java @@ -0,0 +1,116 @@ +package net.dnorberg.rivencraft.client; + +import com.mojang.blaze3d.platform.InputConstants; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.network.BindDescriptiveBookPayload; +import net.dnorberg.rivencraft.network.PortBookNetworking; +import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.client.network.ClientPacketDistributor; + +/** Temporary 26.3 descriptive-book interaction; authored pages and live previews remain unported. */ +public final class PortDescriptiveBookScreen extends Screen { + private static final int WIDTH = 327; + private static final int HEIGHT = 199; + private static final int TEXT_COLOR = 0xFF3B281B; + private static final Identifier COVER = texture("bookui_cover.png"); + private static final Identifier LEFT_PAGE = texture("notebook_pages.png"); + private static final Identifier RIGHT_PANEL = texture("bookui_pager.png"); + + private final InteractionHand hand; + private final boolean bound; + private EditBox titleInput; + + public PortDescriptiveBookScreen(InteractionHand hand, ItemStack book) { + super(book.getHoverName()); + this.hand = hand; + bound = book.has(Rivencraft.AGE_BOOK_ID.get()); + } + + @Override + protected void init() { + if (bound) return; + int left = (width - WIDTH) / 2; + int top = (height - HEIGHT) / 2; + titleInput = new EditBox(font, left + 40, top + 69, 110, 20, + Component.literal("Age name")); + titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH); + titleInput.setHint(Component.literal("Name this Age")); + addRenderableWidget(titleInput); + setInitialFocus(titleInput); + addRenderableWidget(Button.builder(Component.literal("Bind"), button -> bind()) + .bounds(left + 40, top + 100, 53, 20).build()); + addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose()) + .bounds(left + 97, top + 100, 53, 20).build()); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + int left = (width - WIDTH) / 2; + int top = (height - HEIGHT) / 2; + blit(graphics, COVER, left, top + 7, 34, 192, 152, 0); + blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0); + blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0); + blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0); + blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0); + blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0); + graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false); + if (bound) { + graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D); + graphics.text(font, Component.literal("Age preview unavailable"), left + 181, top + 47, + 0xFFE9DFCB, false); + graphics.text(font, Component.literal("Click to enter"), left + 205, top + 65, + 0xFFE9DFCB, false); + } else { + graphics.text(font, Component.literal("Bind a prototype Age"), left + 183, top + 39, + TEXT_COLOR, false); + } + super.extractRenderState(graphics, mouseX, mouseY, partialTick); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { + int left = (width - WIDTH) / 2; + int top = (height - HEIGHT) / 2; + if (event.x() >= left + 173 && event.x() < left + 305 + && event.y() >= top + 20 && event.y() < top + 103) { + ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(hand == InteractionHand.OFF_HAND)); + onClose(); + return true; + } + } + return super.mouseClicked(event, doubleClick); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + private void bind() { + if (titleInput == null) return; + String name = titleInput.getValue().strip(); + if (name.isEmpty()) return; + ClientPacketDistributor.sendToServer(new BindDescriptiveBookPayload(hand == InteractionHand.OFF_HAND, name)); + onClose(); + } + + private static Identifier texture(String filename) { + return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename); + } + + private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y, + int width, int height, int u, int v) { + graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java index e884154..0b3e6a8 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java @@ -2,10 +2,8 @@ package net.dnorberg.rivencraft.item; import java.util.function.Consumer; import net.dnorberg.rivencraft.Rivencraft; -import net.dnorberg.rivencraft.age.PortAgeTravel; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; -import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.player.Player; @@ -15,7 +13,7 @@ import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.component.TooltipDisplay; import net.minecraft.world.level.Level; -/** First 26.3 bound-book slice; authoring and the book GUI still need porting. */ +/** Descriptive Book shell for the 26.3 prototype Age backend. */ public final class DescriptiveBookItem extends Item { public DescriptiveBookItem(Properties properties) { super(properties); @@ -28,18 +26,8 @@ public final class DescriptiveBookItem extends Item { @Override public InteractionResult use(Level level, Player player, InteractionHand hand) { - ItemStack stack = player.getItemInHand(hand); - Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get()); - if (id == null) { - if (player instanceof ServerPlayer serverPlayer) { - serverPlayer.sendOverlayMessage(Component.literal("Unbound Descriptive Book; use the port's Age book probe")); - } - return InteractionResult.SUCCESS; - } - if (player instanceof ServerPlayer serverPlayer) { - String failure = PortAgeTravel.enter(serverPlayer, id); - serverPlayer.sendOverlayMessage(Component.literal(failure == null ? "Entered " + id : failure)); - } + // The client opens the GUI. Binding and travel are validated on the + // server through dedicated packets; item use cannot bypass them. return InteractionResult.SUCCESS; } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BindDescriptiveBookPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BindDescriptiveBookPayload.java new file mode 100644 index 0000000..b689d18 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/BindDescriptiveBookPayload.java @@ -0,0 +1,22 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +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.Identifier; + +public record BindDescriptiveBookPayload(boolean offhand, String title) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_descriptive_book")); + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite(ByteBufCodecs.BOOL, BindDescriptiveBookPayload::offhand, + ByteBufCodecs.stringUtf8(32), BindDescriptiveBookPayload::title, + BindDescriptiveBookPayload::new); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java index 8759e90..f6fb241 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java @@ -1,16 +1,20 @@ package net.dnorberg.rivencraft.network; import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.age.PortAgeTravel; +import net.dnorberg.rivencraft.age.RuntimeAgeBackend; import net.dnorberg.rivencraft.item.LinkBookTarget; import net.dnorberg.rivencraft.item.LinkingBookItem; import net.minecraft.core.component.DataComponents; import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; 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.item.ItemStack; import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import java.util.Locale; /** Server-authoritative handheld Linking Book binding and travel. */ public final class PortBookNetworking { @@ -29,6 +33,14 @@ public final class PortBookNetworking { (payload, context) -> { if (context.player() instanceof ServerPlayer player) use(player, payload); }); + registrar.playToServer(BindDescriptiveBookPayload.TYPE, BindDescriptiveBookPayload.STREAM_CODEC, + (payload, context) -> { + if (context.player() instanceof ServerPlayer player) bindAge(player, payload); + }); + registrar.playToServer(UseDescriptiveBookPayload.TYPE, UseDescriptiveBookPayload.STREAM_CODEC, + (payload, context) -> { + if (context.player() instanceof ServerPlayer player) enterAge(player, payload); + }); } private static void bind(ServerPlayer player, BindLinkingBookPayload payload) { @@ -51,6 +63,45 @@ public final class PortBookNetworking { if (failure != null) player.sendOverlayMessage(Component.literal(failure)); } + private static void bindAge(ServerPlayer player, BindDescriptiveBookPayload payload) { + ItemStack stack = player.getItemInHand(hand(payload.offhand())); + if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()) || stack.has(Rivencraft.AGE_BOOK_ID.get())) return; + String title = payload.title().strip(); + if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return; + String base = title.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "_") + .replaceAll("^[_\\.-]+|[_\\.-]+$", ""); + if (base.isEmpty()) base = "age"; + var server = player.level().getServer(); + String name = base; + int suffix = 2; + Identifier id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name); + while (RuntimeAgeBackend.isNameTaken(server, id)) { + name = base + "_" + suffix++; + id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name); + } + try { + RuntimeAgeBackend.create(server, id, player.getRandom().nextLong()); + } catch (IllegalArgumentException | IllegalStateException exception) { + player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage())); + return; + } + stack.set(Rivencraft.AGE_BOOK_ID.get(), id); + stack.set(DataComponents.CUSTOM_NAME, Component.literal(title)); + player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, + SoundSource.PLAYERS, 1.0F, 1.0F); + player.sendOverlayMessage(Component.literal("Bound Descriptive Book: " + title)); + player.containerMenu.broadcastChanges(); + } + + private static void enterAge(ServerPlayer player, UseDescriptiveBookPayload payload) { + ItemStack stack = player.getItemInHand(hand(payload.offhand())); + if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get())) return; + Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get()); + if (id == null) return; + String failure = PortAgeTravel.enter(player, id); + if (failure != null) player.sendOverlayMessage(Component.literal(failure)); + } + private static InteractionHand hand(boolean offhand) { return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND; } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseDescriptiveBookPayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseDescriptiveBookPayload.java new file mode 100644 index 0000000..1139284 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/UseDescriptiveBookPayload.java @@ -0,0 +1,21 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +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.Identifier; + +public record UseDescriptiveBookPayload(boolean offhand) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_descriptive_book")); + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite(ByteBufCodecs.BOOL, UseDescriptiveBookPayload::offhand, + UseDescriptiveBookPayload::new); + + @Override + public Type type() { + return TYPE; + } +}