Add 26.3 descriptive book binding and travel

This commit is contained in:
dnorberg committed 2026-10-29 14:32:50 -04:00
1 parent eda19de43c
commit d3fea0b331
11 files changed
+276 -22

No files matched your search

+37
View File
@@ -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.
+7
View File
@@ -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 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. 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 ## Verification of this first slice
The 1.21.1 `compileJava` and `build` tasks passed offline after the backend The 1.21.1 `compileJava` and `build` tasks passed offline after the backend
+4 -2
View File
@@ -3,8 +3,10 @@
This is an **experimental alpha port** for issues #97–#104, not gameplay parity 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 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` prototype with persisted seeds. The operator-only `rivencraft_port age`
commands create and inspect separate vanilla-terrain levels. Descriptive Book commands create and inspect separate vanilla-terrain levels. The current
travel and named Linking Book binding/travel work as limited probes. Authored 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 generation, most content, recipes, workstations, portal systems, modifiers and
the destination preview renderer remain unported. Use a test world and keep a 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. backup; do not substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
+1 -1
View File
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
mod_id=rivencraft mod_id=rivencraft
mod_name=Rivencraft mod_name=Rivencraft
mod_license=LGPL-3.0-or-later mod_license=LGPL-3.0-or-later
mod_version=1.0.1.2-alpha.1 mod_version=1.0.1.3-alpha.1
@@ -153,6 +153,13 @@ public final class RuntimeAgeBackend {
return index(server).entries(); 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) { private static AgeLevelIndex index(MinecraftServer server) {
return server.overworld().getDataStorage().computeIfAbsent(AgeLevelIndex.TYPE); return server.overworld().getDataStorage().computeIfAbsent(AgeLevelIndex.TYPE);
} }
@@ -16,11 +16,14 @@ public final class PortBookInteractionHandler {
@SubscribeEvent @SubscribeEvent
public static void onRightClickItem(PlayerInteractEvent.RightClickItem event) { public static void onRightClickItem(PlayerInteractEvent.RightClickItem event) {
if (!event.getEntity().level().isClientSide() if (!event.getEntity().level().isClientSide()) return;
|| !event.getItemStack().is(Rivencraft.LINKING_BOOK.get())) 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.setCanceled(true);
event.setCancellationResult(InteractionResult.SUCCESS); event.setCancellationResult(InteractionResult.SUCCESS);
Minecraft.getInstance().setScreenAndShow(new PortLinkingBookScreen( Minecraft.getInstance().setScreenAndShow(linking
event.getHand(), event.getItemStack().copy())); ? new PortLinkingBookScreen(event.getHand(), event.getItemStack().copy())
: new PortDescriptiveBookScreen(event.getHand(), event.getItemStack().copy()));
} }
} }
@@ -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);
}
}
@@ -2,10 +2,8 @@ package net.dnorberg.rivencraft.item;
import java.util.function.Consumer; import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortAgeTravel;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult; import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player; 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.item.component.TooltipDisplay;
import net.minecraft.world.level.Level; 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 final class DescriptiveBookItem extends Item {
public DescriptiveBookItem(Properties properties) { public DescriptiveBookItem(Properties properties) {
super(properties); super(properties);
@@ -28,18 +26,8 @@ public final class DescriptiveBookItem extends Item {
@Override @Override
public InteractionResult use(Level level, Player player, InteractionHand hand) { public InteractionResult use(Level level, Player player, InteractionHand hand) {
ItemStack stack = player.getItemInHand(hand); // The client opens the GUI. Binding and travel are validated on the
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get()); // server through dedicated packets; item use cannot bypass them.
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));
}
return InteractionResult.SUCCESS; return InteractionResult.SUCCESS;
} }
@@ -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<BindDescriptiveBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_descriptive_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, BindDescriptiveBookPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.BOOL, BindDescriptiveBookPayload::offhand,
ByteBufCodecs.stringUtf8(32), BindDescriptiveBookPayload::title,
BindDescriptiveBookPayload::new);
@Override
public Type<BindDescriptiveBookPayload> type() {
return TYPE;
}
}
@@ -1,16 +1,20 @@
package net.dnorberg.rivencraft.network; package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft; 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.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.minecraft.core.component.DataComponents; import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents; import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource; import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
import java.util.Locale;
/** Server-authoritative handheld Linking Book binding and travel. */ /** Server-authoritative handheld Linking Book binding and travel. */
public final class PortBookNetworking { public final class PortBookNetworking {
@@ -29,6 +33,14 @@ public final class PortBookNetworking {
(payload, context) -> { (payload, context) -> {
if (context.player() instanceof ServerPlayer player) use(player, payload); 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) { private static void bind(ServerPlayer player, BindLinkingBookPayload payload) {
@@ -51,6 +63,45 @@ public final class PortBookNetworking {
if (failure != null) player.sendOverlayMessage(Component.literal(failure)); 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) { private static InteractionHand hand(boolean offhand) {
return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND; return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
} }
@@ -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<UseDescriptiveBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_descriptive_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, UseDescriptiveBookPayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.BOOL, UseDescriptiveBookPayload::offhand,
UseDescriptiveBookPayload::new);
@Override
public Type<UseDescriptiveBookPayload> type() {
return TYPE;
}
}