Add incomplete Minecraft 26.3 port prototype
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-27 19:09:19 -04:00
1 parent 4f07f675a4
commit 0fb2f7c1bb
30 files changed
+1981

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# Minecraft 26.3 port workspace
This is an **incomplete mod port** for issues #97–#103. It currently registers
the Crystal Shard and Crystal Block to verify NeoForge 26.3 loading, block and
item registration, and modern item resources. It also has a server-only
runtime Age prototype: the operator-only `rivencraft_port age create <name>
<seed>`, `list`, and `sample <name>` commands exercise separate vanilla-terrain
levels with persisted seeds. Authored generators, book binding and client
dimension synchronization are **not** ported, so it is not playable. Its JAR
task is disabled. Do not upload output
from this directory to Gitea, CurseForge, or Modrinth.
From the repository root:
```sh
./gradlew -p ports/mc263 build
```
This standalone Gradle build uses Minecraft 26.3, NeoForge 26.3.0.99,
ModDevGradle 2.0.148, and Java 25. The repository root remains the independent
Minecraft 1.21.1/Java 21 build:
```sh
./gradlew build
```
Continue migrating the actual mod in issues #98–#101. Only enable JAR
assembly once the dual-version validation in #103 is complete.
The dedicated development server uses `build/run-server`, with its own world
and properties. Set a nonconflicting `server-port` there before running if
another Minecraft server is active. The prototype restores all indexed Ages
during the Overworld load, before initial chunk preparation completes.
+94
View File
@@ -0,0 +1,94 @@
plugins {
id 'java-library'
id 'net.neoforged.moddev' version '2.0.148'
}
group = 'net.dnorberg.rivencraft'
version = mod_version
base {
archivesName = "rivencraft-${minecraft_version}-INCOMPLETE"
}
java.toolchain.languageVersion = JavaLanguageVersion.of(25)
neoForge {
version = project.neo_version
runs {
client {
client()
if (project.hasProperty('connectAddress')) {
programArgument '--quickPlayMultiplayer'
programArgument project.property('connectAddress').toString()
}
if (project.hasProperty('clientName')) {
programArgument '--username'
programArgument project.property('clientName').toString()
gameDirectory = project.layout.buildDirectory.dir('run-client-' + project.property('clientName')).get().asFile
}
}
server {
server()
gameDirectory = project.hasProperty('serverRunDir')
? file(project.property('serverRunDir').toString())
: project.layout.buildDirectory.dir('run-server').get().asFile
programArgument '--nogui'
}
}
mods {
rivencraft {
sourceSet(sourceSets.main)
}
}
}
def generateModMetadata = tasks.register('generateModMetadata', ProcessResources) {
def replaceProperties = [
mod_id: mod_id,
mod_name: mod_name,
mod_license: mod_license,
mod_version: mod_version,
neo_version: neo_version,
minecraft_version_range: minecraft_version_range,
]
inputs.properties replaceProperties
expand replaceProperties
from 'src/main/templates'
into 'build/generated/sources/modMetadata'
}
sourceSets.main.resources.srcDir generateModMetadata
neoForge.ideSyncTask generateModMetadata
tasks.named('processResources', ProcessResources).configure {
// Reuse the existing sprite and base model without copying binary assets.
from('../../src/main/resources') {
include 'assets/rivencraft/textures/item/crystal_shard.png'
include 'assets/rivencraft/models/item/crystal_shard.json'
include 'assets/rivencraft/textures/item/descriptive_book.png'
include 'assets/rivencraft/models/item/descriptive_book.json'
include 'assets/rivencraft/textures/item/linking_book.png'
include 'assets/rivencraft/models/item/linking_book_bound.json'
include 'assets/rivencraft/textures/gui/bookui_cover.png'
include 'assets/rivencraft/textures/gui/notebook_pages.png'
include 'assets/rivencraft/textures/gui/bookui_pager.png'
include 'assets/rivencraft/textures/block/crystal_block.png'
include 'assets/rivencraft/models/block/crystal_block.json'
include 'assets/rivencraft/blockstates/crystal_block.json'
include 'data/rivencraft/loot_table/blocks/crystal_block.json'
}
}
// This partial 26.3 port is not a playable release. An explicitly requested
// local test JAR can be built with -PallowIncompletePortJar=true, but the
// default build must never produce a distributable artifact.
tasks.named('jar').configure {
enabled = providers.gradleProperty('allowIncompletePortJar').map(String::toBoolean).getOrElse(false)
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
tasks.named('runServer').configure {
standardInput = System.in
}
+10
View File
@@ -0,0 +1,10 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.caching=true
minecraft_version=26.3
minecraft_version_range=[26.3]
neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=0.0.0.7-port-dev
+11
View File
@@ -0,0 +1,11 @@
pluginManagement {
repositories {
gradlePluginPortal()
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
rootProject.name = 'rivencraft-mc263-port'
@@ -0,0 +1,67 @@
package net.dnorberg.rivencraft;
import com.mojang.logging.LogUtils;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.dnorberg.rivencraft.age.PortAgeCommands;
import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.registries.DeferredBlock;
import net.neoforged.neoforge.registries.DeferredItem;
import net.neoforged.neoforge.registries.DeferredRegister;
import java.util.function.Supplier;
import net.neoforged.neoforge.common.NeoForge;
import org.slf4j.Logger;
/** First 26.3 registry slice; gameplay remains in the 1.21.1 target for now. */
@Mod(Rivencraft.MOD_ID)
public final class Rivencraft {
public static final String MOD_ID = "rivencraft";
public static final Logger LOGGER = LogUtils.getLogger();
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister.DataComponents COMPONENTS =
DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MOD_ID);
public static final Supplier<DataComponentType<Identifier>> AGE_BOOK_ID = COMPONENTS.registerComponentType(
"age_book_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
public static final Supplier<DataComponentType<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK);
public static final DeferredItem<Item> CRYSTAL_SHARD = ITEMS.registerSimpleItem("crystal_shard");
public static final DeferredItem<DescriptiveBookItem> DESCRIPTIVE_BOOK = ITEMS.registerItem(
"descriptive_book", DescriptiveBookItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<LinkingBookItem> LINKING_BOOK = ITEMS.registerItem(
"linking_book", LinkingBookItem::new, properties -> properties.stacksTo(1));
public Rivencraft(IEventBus modEventBus) {
BLOCKS.register(modEventBus);
ITEMS.register(modEventBus);
COMPONENTS.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
modEventBus.addListener(AgeCatalogSync::registerPayloads);
modEventBus.addListener(PortBookNetworking::registerPayloads);
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
}
}
@@ -0,0 +1,22 @@
package net.dnorberg.rivencraft.age;
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;
/** Acknowledges the exact catalog version applied on the client main thread. */
public record AgeCatalogAckPayload(int version) implements CustomPacketPayload {
public static final Type<AgeCatalogAckPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog_ack"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogAckPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, AgeCatalogAckPayload::version, AgeCatalogAckPayload::new
);
@Override
public Type<AgeCatalogAckPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,29 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
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;
/** Full server level catalog, sent on login and after a runtime Age is added. */
public record AgeCatalogPayload(int version, List<Identifier> dimensions) implements CustomPacketPayload {
public static final Type<AgeCatalogPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, AgeCatalogPayload::version,
Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions,
AgeCatalogPayload::new
);
public AgeCatalogPayload {
dimensions = List.copyOf(dimensions);
}
@Override
public Type<AgeCatalogPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,102 @@
package net.dnorberg.rivencraft.age;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
import net.neoforged.neoforge.network.PacketDistributor;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
/** Refuses runtime-Age travel until the connected client confirms its catalog. */
public final class AgeCatalogSync {
private static final Map<MinecraftServer, State> STATES = new IdentityHashMap<>();
private AgeCatalogSync() {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) acknowledge(player, payload.version());
});
}
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
try {
send(player);
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not send initial Age catalog to {}; travel remains blocked",
player.getName().getString(), exception);
}
}
}
public static void onLogout(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
State state = STATES.get(player.level().getServer());
if (state != null) state.acknowledged.remove(player.getUUID());
}
}
public static void onServerStopped(ServerStoppedEvent event) {
STATES.remove(event.getServer());
}
public static void onCatalogChanged(MinecraftServer server) {
State state = state(server);
state.version++;
state.acknowledged.clear();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
try {
send(player);
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not send Age catalog to {}; travel remains blocked",
player.getName().getString(), exception);
}
}
}
public static boolean ready(ServerPlayer player, Identifier target) {
MinecraftServer server = player.level().getServer();
State state = state(server);
return state.version == state.acknowledged.getOrDefault(player.getUUID(), -1)
&& server.levelKeys().stream().anyMatch(key -> key.identifier().equals(target));
}
private static void send(ServerPlayer player) {
MinecraftServer server = player.level().getServer();
State state = state(server);
state.acknowledged.remove(player.getUUID());
var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList();
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions));
}
private static void acknowledge(ServerPlayer player, int version) {
State state = state(player.level().getServer());
if (version != state.version) {
Rivencraft.LOGGER.warn("Ignoring stale Age catalog acknowledgement {} from {} (current {})",
version, player.getName().getString(), state.version);
return;
}
state.acknowledged.put(player.getUUID(), version);
Rivencraft.LOGGER.info("Age catalog {} acknowledged by {}", version, player.getName().getString());
}
private static State state(MinecraftServer server) {
return STATES.computeIfAbsent(server, ignored -> new State());
}
private static final class State {
private int version = 1;
private final Map<UUID, Integer> acknowledged = new HashMap<>();
}
}
@@ -0,0 +1,79 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Server-owned seed index for the first-party 26.3 runtime-level prototype. */
final class AgeLevelIndex extends SavedData {
private static final Codec<AgeLevelIndex> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.unboundedMap(Identifier.CODEC, Codec.LONG).fieldOf("ages").forGetter(index -> index.ages),
Identifier.CODEC.listOf().optionalFieldOf("retired", List.of())
.forGetter(index -> List.copyOf(index.retired))
).apply(instance, AgeLevelIndex::new));
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "runtime_ages"),
AgeLevelIndex::new,
CODEC
);
private final Map<Identifier, Long> ages;
private final Set<Identifier> retired;
private AgeLevelIndex() {
this.ages = new LinkedHashMap<>();
this.retired = new LinkedHashSet<>();
}
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired) {
this.ages = new LinkedHashMap<>(ages);
this.retired = new LinkedHashSet<>(retired);
}
Map<Identifier, Long> entries() {
return Map.copyOf(ages);
}
boolean contains(Identifier id) {
return ages.containsKey(id) || retired.contains(id);
}
Long seed(Identifier id) {
return ages.get(id);
}
void add(Identifier id, long seed) {
if (contains(id)) {
throw new IllegalArgumentException("Age already exists: " + id);
}
ages.put(id, seed);
setDirty();
}
void retire(Identifier id) {
if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id);
retired.add(id);
setDirty();
}
void restoreAfterFailedRetirement(Identifier id, long seed) {
if (!retired.remove(id)) throw new IllegalStateException("Age is not retired: " + id);
ages.put(id, seed);
setDirty();
}
void reseed(Identifier id, long seed) {
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
ages.put(id, seed);
setDirty();
}
}
@@ -0,0 +1,221 @@
package net.dnorberg.rivencraft.age;
import com.mojang.brigadier.arguments.LongArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.commands.Commands;
import net.minecraft.core.registries.Registries;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import net.neoforged.fml.ModList;
import java.util.Map;
import java.util.TreeMap;
/** Operator-only backend and synchronized-travel probes, not normal book behavior. */
public final class PortAgeCommands {
private PortAgeCommands() {
}
public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft_port")
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
.then(Commands.literal("compat")
.executes(context -> {
StringBuilder mods = new StringBuilder("Optional mods: ");
for (String id : new String[]{"jei", "terrablender", "biomesoplenty",
"sereneseasons", "glitchcore", "ruins", "create",
"ponder", "immersive_portals_core"}) {
if (mods.length() > "Optional mods: ".length()) mods.append(", ");
mods.append(id).append('=').append(ModList.get().isLoaded(id));
}
context.getSource().sendSuccess(() -> Component.literal(mods.toString()), false);
Map<String, Integer> biomeCounts = new TreeMap<>();
context.getSource().getServer().registryAccess()
.lookupOrThrow(Registries.BIOME).listElementIds()
.forEach(key -> biomeCounts.merge(key.identifier().getNamespace(), 1, Integer::sum));
context.getSource().sendSuccess(() -> Component.literal(
"Registered biomes by namespace: " + biomeCounts), false);
return 1;
}))
.then(Commands.literal("link")
.then(Commands.literal("book")
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
LinkBookTarget target = LinkBookTarget.at(player);
book.set(Rivencraft.LINK_BOOK_TARGET.get(), target);
book.set(DataComponents.CUSTOM_NAME, Component.literal(
"Link to " + target.dimension()));
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
context.getSource().sendSuccess(() -> Component.literal(
"Granted Linking Book bound to " + target.dimension() + " at "
+ player.blockPosition()), false);
return 1;
})))
.then(Commands.literal("age")
.then(Commands.literal("create")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "age/" + name);
long seed = LongArgumentType.getLong(context, "seed");
ServerLevel level;
try {
level = RuntimeAgeBackend.create(
context.getSource().getServer(), id, seed);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Created " + id + " with seed " + level.getSeed()), true);
return 1;
}))))
.then(Commands.literal("list")
.executes(context -> {
var ages = RuntimeAgeBackend.savedAges(context.getSource().getServer());
context.getSource().sendSuccess(() -> Component.literal(
"Saved prototype Ages: " + ages), false);
return ages.size();
}))
.then(Commands.literal("retire")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "age/" + name);
try {
RuntimeAgeBackend.retire(context.getSource().getServer(), id);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Retired " + id + "; exact Age data archived"), true);
return 1;
})))
.then(Commands.literal("unload")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
Identifier id = ageId(context);
try {
RuntimeAgeBackend.unload(context.getSource().getServer(), id);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Unloaded " + id), true);
return 1;
})))
.then(Commands.literal("reload")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
Identifier id = ageId(context);
try {
RuntimeAgeBackend.reload(context.getSource().getServer(), id);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Reloaded " + id), true);
return 1;
})))
.then(Commands.literal("reseed")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
Identifier id = ageId(context);
long seed = LongArgumentType.getLong(context, "seed");
try {
RuntimeAgeBackend.reseed(context.getSource().getServer(), id, seed);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Reseeded " + id + " to " + seed + " for future chunks"), true);
return 1;
}))))
.then(Commands.literal("enter")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
Identifier id = ageId(context);
String failure = PortAgeTravel.enter(player, id);
if (failure != null) {
context.getSource().sendFailure(Component.literal(failure));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Entered " + id + " after Age catalog acknowledgement"), false);
return 1;
})))
.then(Commands.literal("book")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
Identifier id = ageId(context);
if (!RuntimeAgeBackend.savedAges(context.getSource().getServer()).containsKey(id)) {
context.getSource().sendFailure(Component.literal("Age not active: " + id));
return 0;
}
ItemStack book = new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get());
book.set(Rivencraft.AGE_BOOK_ID.get(), id);
book.set(DataComponents.CUSTOM_NAME, Component.literal(
StringArgumentType.getString(context, "name")));
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
context.getSource().sendSuccess(() -> Component.literal(
"Granted bound Descriptive Book for " + id), false);
return 1;
})))
.then(Commands.literal("sample")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> sampleAge(context, 0))
.then(Commands.argument("chunkOffset", IntegerArgumentType.integer(0))
.executes(context -> sampleAge(context,
IntegerArgumentType.getInteger(context, "chunkOffset"))))))));
}
private static int sampleAge(com.mojang.brigadier.context.CommandContext<net.minecraft.commands.CommandSourceStack> context,
int chunkOffset) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
int fingerprint = 1;
for (int x = 0; x < 2; x++) {
for (int z = 0; z < 2; z++) {
int chunkX = chunkOffset + x * 4;
int chunkZ = chunkOffset + z * 4;
level.getChunk(chunkX, chunkZ);
fingerprint = 31 * fingerprint + level.getHeight(
Heightmap.Types.WORLD_SURFACE, chunkX * 16, chunkZ * 16);
}
}
int result = fingerprint;
context.getSource().sendSuccess(() -> Component.literal(
id + " seed=" + level.getSeed() + " chunkOffset=" + chunkOffset + " terrain=" + result), false);
return 1;
}
private static Identifier ageId(com.mojang.brigadier.context.CommandContext<net.minecraft.commands.CommandSourceStack> context) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"age/" + StringArgumentType.getString(context, "name"));
}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.age;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
/** Shared, server-authoritative landing for the 26.3 book and operator probe. */
public final class PortAgeTravel {
private PortAgeTravel() {
}
public static String enter(ServerPlayer player, Identifier id) {
ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (target == null) return "Age not loaded: " + id;
if (!AgeCatalogSync.ready(player, id)) {
return "Age catalog not acknowledged by client; remaining in source dimension";
}
BlockPos landing = findLanding(target);
if (landing == null) return "No safe test landing available; remaining in source dimension";
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
return moved ? null : "Teleportation failed; remaining in source dimension";
}
private static BlockPos findLanding(ServerLevel target) {
for (int radius = 0; radius <= 16; radius++) {
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
int y = target.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z);
BlockPos foot = new BlockPos(x, y, z);
BlockPos floor = foot.below();
if (target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
&& target.getBlockState(foot).getCollisionShape(target, foot).isEmpty()
&& target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty()) {
return foot;
}
}
}
}
// The operator/book probe needs a deterministic landing even when
// origin is ocean or foliage. Final book landing rules are ported later.
int y = Math.max(64, target.getHeight(Heightmap.Types.MOTION_BLOCKING, 0, 0) + 2);
if (y + 2 >= target.getMaxY()) return null;
BlockPos foot = new BlockPos(0, y, 0);
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
target.setBlock(new BlockPos(x, y - 1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
}
}
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
target.setBlock(foot.above(), Blocks.AIR.defaultBlockState(), 3);
return foot;
}
}
@@ -0,0 +1,269 @@
package net.dnorberg.rivencraft.age;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.TicketStorage;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.storage.DerivedLevelData;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.world.chunk.ForcedChunkManager;
import net.neoforged.neoforge.event.level.LevelEvent;
/**
* Server-side runtime-level foundation for 26.3. Authored generators and
* dynamic client registry synchronization remain separate migration steps.
*/
public final class RuntimeAgeBackend {
private RuntimeAgeBackend() {
}
public static void onLevelLoad(LevelEvent.Load event) {
if (!(event.getLevel() instanceof ServerLevel overworld) || overworld.dimension() != Level.OVERWORLD) {
return;
}
MinecraftServer server = overworld.getServer();
AgeLevelIndex index = index(server);
for (Map.Entry<Identifier, Long> age : index.entries().entrySet()) {
try {
load(server, age.getKey(), age.getValue());
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not restore Age {}. Its saved index remains intact.", age.getKey(), exception);
}
}
}
/** Creates a server level, then commits its identity only after successful construction. */
public static ServerLevel create(MinecraftServer server, Identifier id, long seed) {
checkServerThread(server);
ageName(id);
AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
ServerLevel level = load(server, id, seed);
index.add(id, seed);
// A newly bound book may be used immediately. Persist its Age identity
// before publishing the catalog so a later crash cannot leave that
// book pointing at a world omitted from the on-disk index.
server.overworld().getDataStorage().saveAndJoin();
AgeCatalogSync.onCatalogChanged(server);
return level;
}
/** Unloads an Age only when no player remains; save(true) flushes other entities. */
public static void unload(MinecraftServer server, Identifier id) {
checkServerThread(server);
requireSeed(server, id);
unloadLoaded(server, id);
AgeCatalogSync.onCatalogChanged(server);
}
public static ServerLevel reload(MinecraftServer server, Identifier id) {
checkServerThread(server);
long seed = requireSeed(server, id);
ServerLevel level = load(server, id, seed);
AgeCatalogSync.onCatalogChanged(server);
return level;
}
/** Existing saved chunks stay in place; the replacement level uses the new seed. */
public static void reseed(MinecraftServer server, Identifier id, long seed) {
checkServerThread(server);
AgeLevelIndex index = index(server);
long previousSeed = requireSeed(server, id);
if (previousSeed == seed) return;
boolean loaded = server.getLevel(dimensionKey(id)) != null;
if (loaded) unloadLoaded(server, id);
try {
if (loaded) load(server, id, seed);
index.reseed(id, seed);
server.overworld().getDataStorage().saveAndJoin();
if (loaded) AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException exception) {
if (loaded) {
try {
load(server, id, previousSeed);
AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
}
throw exception;
}
}
/** Retires one unloaded Age and moves only its own storage to a recoverable archive. */
public static void retire(MinecraftServer server, Identifier id) {
checkServerThread(server);
String name = ageName(id);
AgeLevelIndex index = index(server);
long seed = requireSeed(server, id);
boolean loaded = server.getLevel(dimensionKey(id)) != null;
ArchivePaths paths = archivePaths(server, name);
if (loaded) unloadLoaded(server, id);
// Persist the tombstone before moving files: a crash mid-archive must
// not let an old book silently regenerate this identity.
index.retire(id);
try {
server.overworld().getDataStorage().saveAndJoin();
if (Files.exists(paths.live(), LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(paths.archive().getParent());
try {
Files.move(paths.live(), paths.archive(), StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(paths.live(), paths.archive());
}
}
} catch (IOException | RuntimeException exception) {
try {
index.restoreAfterFailedRetirement(id, seed);
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
if (loaded) {
try {
load(server, id, seed);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
}
throw new IllegalStateException("Could not archive Age " + id, exception);
}
AgeCatalogSync.onCatalogChanged(server);
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
}
public static Map<Identifier, Long> savedAges(MinecraftServer server) {
checkServerThread(server);
return index(server).entries();
}
private static AgeLevelIndex index(MinecraftServer server) {
return server.overworld().getDataStorage().computeIfAbsent(AgeLevelIndex.TYPE);
}
private static void checkServerThread(MinecraftServer server) {
if (!server.isSameThread()) throw new IllegalStateException("Runtime Age operation outside server thread");
}
private static long requireSeed(MinecraftServer server, Identifier id) {
ageName(id);
Long seed = index(server).seed(id);
if (seed == null) throw new IllegalArgumentException("Age not active: " + id);
return seed;
}
private static String ageName(Identifier id) {
if (!id.getNamespace().equals(Rivencraft.MOD_ID) || !id.getPath().startsWith("age/")) {
throw new IllegalArgumentException("Expected a rivencraft:age/* dimension ID: " + id);
}
String name = id.getPath().substring("age/".length());
if (name.isBlank() || name.contains("/") || name.equals(".") || name.equals("..")) {
throw new IllegalArgumentException("Expected a single safe Age path segment: " + id);
}
return name;
}
private static ResourceKey<Level> dimensionKey(Identifier id) {
return ResourceKey.create(Registries.DIMENSION, id);
}
private static void unloadLoaded(MinecraftServer server, Identifier id) {
ResourceKey<Level> key = dimensionKey(id);
ServerLevel level = server.getLevel(key);
if (level == null) throw new IllegalStateException("Age not loaded: " + id);
if (!level.players().isEmpty()) {
throw new IllegalStateException("Age still contains players; move them before unloading: " + id);
}
// Match the server shutdown sequence so forced tickets are persisted
// as inactive, rather than holding stale references to this level.
level.getChunkSource().deactivateTicketsOnClosing();
level.getChunkSource().tick(() -> true, false);
level.save(null, true, false);
try {
level.close();
} catch (IOException exception) {
throw new IllegalStateException("Could not close Age: " + id, exception);
}
server.levels.remove(key);
NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));
Rivencraft.LOGGER.info("Unloaded 26.3 prototype Age {}", id);
}
private static ArchivePaths archivePaths(MinecraftServer server, String name) {
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path dimensions = world.resolve("dimensions");
Path namespace = dimensions.resolve(Rivencraft.MOD_ID);
Path ageRoot = namespace.resolve("age");
Path live = ageRoot.resolve(name).normalize();
Path archiveRoot = world.getParent().resolve(world.getFileName() + "-retired-ages").normalize();
Path archive = archiveRoot.resolve(name).normalize();
if (!live.getParent().equals(ageRoot) || !archive.getParent().equals(archiveRoot)
|| Files.isSymbolicLink(world) || Files.isSymbolicLink(dimensions)
|| Files.isSymbolicLink(namespace) || Files.isSymbolicLink(ageRoot)
|| Files.isSymbolicLink(live) || Files.isSymbolicLink(archiveRoot)
|| Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("Unsafe or existing Age archive path for " + name);
}
return new ArchivePaths(live, archive);
}
private record ArchivePaths(Path live, Path archive) {
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed) {
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
// This prototype uses vanilla Overworld terrain until issue #98 ports
// authored ChunkGenerators. LevelStem.seedOverride controls chunk random
// state as well as ServerLevel.getSeed() in NeoForge 26.3.
LevelStem overworldStem = server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM)
.getValue(LevelStem.OVERWORLD);
if (overworldStem == null) throw new IllegalStateException("Missing Overworld LevelStem");
LevelStem stem = new LevelStem(overworldStem.type(), overworldStem.generator(), OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
ServerLevel level = new ServerLevel(server, server.executor, server.storageSource,
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false);
// Publish only after the constructor succeeds. If the load event fails,
// roll back the map and close the newly opened chunk/entity stores.
try {
server.levels.put(key, level);
level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize());
server.getPlayerList().addWorldborderListener(level);
NeoForge.EVENT_BUS.post(new LevelEvent.Load(level));
TicketStorage tickets = level.getDataStorage().get(TicketStorage.TYPE);
if (tickets != null) {
tickets.activateAllDeactivatedTickets();
ForcedChunkManager.activateAllDeactivatedTickets(level, tickets);
}
Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed);
return level;
} catch (RuntimeException exception) {
server.levels.remove(key);
try {
level.close();
} catch (IOException closeException) {
exception.addSuppressed(closeException);
}
throw exception;
}
}
}
@@ -0,0 +1,31 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.AgeCatalogAckPayload;
import net.dnorberg.rivencraft.age.AgeCatalogPayload;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.network.event.RegisterClientPayloadHandlersEvent;
import java.util.stream.Collectors;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class AgeCatalogClient {
private AgeCatalogClient() {
}
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
listener.levels = payload.dimensions().stream()
.map(id -> ResourceKey.<Level>create(Registries.DIMENSION, id))
.collect(Collectors.toUnmodifiableSet());
context.reply(new AgeCatalogAckPayload(payload.version()));
});
}
}
@@ -0,0 +1,26 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.client.Minecraft;
import net.minecraft.world.InteractionResult;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
/** Opens screens only on the physical client, including integrated singleplayer. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class PortBookInteractionHandler {
private PortBookInteractionHandler() {
}
@SubscribeEvent
public static void onRightClickItem(PlayerInteractEvent.RightClickItem event) {
if (!event.getEntity().level().isClientSide()
|| !event.getItemStack().is(Rivencraft.LINKING_BOOK.get())) return;
event.setCanceled(true);
event.setCancellationResult(InteractionResult.SUCCESS);
Minecraft.getInstance().setScreenAndShow(new PortLinkingBookScreen(
event.getHand(), event.getItemStack().copy()));
}
}
@@ -0,0 +1,114 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.network.BindLinkingBookPayload;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.UseLinkingBookPayload;
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;
/** Minimal 26.3 book interaction using the Legacy artwork; live preview is not ported yet. */
public final class PortLinkingBookScreen 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 PortLinkingBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
this.hand = hand;
bound = book.has(Rivencraft.LINK_BOOK_TARGET.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("Linking Book name"));
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.literal("Name this link"));
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);
if (bound) 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("Destination preview"), left + 185, top + 47, 0xFFE9DFCB, false);
graphics.text(font, Component.literal("Click to link"), left + 205, top + 65, 0xFFE9DFCB, false);
} else {
graphics.text(font, Component.literal("Name this Linking Book"), 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 UseLinkingBookPayload(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 BindLinkingBookPayload(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);
}
}
@@ -0,0 +1,52 @@
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;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
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. */
public final class DescriptiveBookItem extends Item {
public DescriptiveBookItem(Properties properties) {
super(properties);
}
@Override
public boolean isFoil(ItemStack stack) {
return stack.has(Rivencraft.AGE_BOOK_ID.get()) || super.isFoil(stack);
}
@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));
}
return InteractionResult.SUCCESS;
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
tooltip.accept(Component.literal(id == null ? "Unbound (26.3 port preview)" : "Age: " + id));
}
}
@@ -0,0 +1,40 @@
package net.dnorberg.rivencraft.item;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
/** Persistent destination captured when a 26.3 prototype Linking Book is bound. */
public record LinkBookTarget(Identifier dimension, double x, double y, double z, float yaw, float pitch) {
public static final Codec<LinkBookTarget> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("dimension").forGetter(LinkBookTarget::dimension),
Codec.DOUBLE.fieldOf("x").forGetter(LinkBookTarget::x),
Codec.DOUBLE.fieldOf("y").forGetter(LinkBookTarget::y),
Codec.DOUBLE.fieldOf("z").forGetter(LinkBookTarget::z),
Codec.FLOAT.fieldOf("yaw").forGetter(LinkBookTarget::yaw),
Codec.FLOAT.fieldOf("pitch").forGetter(LinkBookTarget::pitch)
).apply(instance, LinkBookTarget::new));
public static final StreamCodec<RegistryFriendlyByteBuf, LinkBookTarget> STREAM_CODEC = StreamCodec.composite(
Identifier.STREAM_CODEC, LinkBookTarget::dimension,
ByteBufCodecs.DOUBLE, LinkBookTarget::x,
ByteBufCodecs.DOUBLE, LinkBookTarget::y,
ByteBufCodecs.DOUBLE, LinkBookTarget::z,
ByteBufCodecs.FLOAT, LinkBookTarget::yaw,
ByteBufCodecs.FLOAT, LinkBookTarget::pitch,
LinkBookTarget::new);
public static LinkBookTarget at(ServerPlayer player) {
return new LinkBookTarget(player.level().dimension().identifier(),
player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot());
}
public boolean valid() {
return Double.isFinite(x) && Double.isFinite(y) && Double.isFinite(z)
&& Float.isFinite(yaw) && Float.isFinite(pitch)
&& Math.abs(x) <= 30_000_000 && Math.abs(z) <= 30_000_000;
}
}
@@ -0,0 +1,77 @@
package net.dnorberg.rivencraft.item;
import java.util.Set;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
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.item.component.TooltipDisplay;
import net.minecraft.world.level.Level;
/** Handheld Linking Book; destination binding and travel are server-authoritative packets. */
public final class LinkingBookItem extends Item {
public LinkingBookItem(Properties properties) {
super(properties);
}
@Override
public boolean isFoil(ItemStack stack) {
return stack.has(Rivencraft.LINK_BOOK_TARGET.get()) || super.isFoil(stack);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
// Client-side interaction opens the book screen. A direct server-side
// item-use packet must never bypass the screen's validated payloads.
return InteractionResult.SUCCESS;
}
public static String travel(ServerPlayer player, LinkBookTarget destination) {
if (destination == null) return "Unbound Linking Book (26.3 port preview)";
if (!destination.valid()) return "Invalid Linking Book destination";
if (player.level().dimension().identifier().equals(destination.dimension())) {
return "Same-dimension linking requires an Intra-Linking page";
}
ServerLevel target = player.level().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, destination.dimension()));
if (target == null) return "Destination dimension is unavailable: " + destination.dimension();
if (!AgeCatalogSync.ready(player, destination.dimension())) {
return "Destination catalog not acknowledged by client";
}
BlockPos feet = BlockPos.containing(destination.x(), destination.y(), destination.z());
if (feet.getY() < target.getMinY() || feet.getY() + 1 >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(feet)) {
return "Destination is outside the world bounds";
}
// Test the player's actual arrival volume: checking whole block shapes
// would incorrectly reject links bound while standing on a slab.
if (!target.noCollision(player, player.getBoundingBox().move(
destination.x() - player.getX(), destination.y() - player.getY(),
destination.z() - player.getZ()))) {
return "Linked destination is obstructed";
}
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
Set.of(), destination.yaw(), destination.pitch(), true);
return moved ? null : "Teleportation failed";
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
LinkBookTarget target = stack.get(Rivencraft.LINK_BOOK_TARGET.get());
tooltip.accept(Component.literal(target == null ? "Unbound (26.3 port preview)"
: "Destination: " + target.dimension() + " (" + (int) target.x() + ", "
+ (int) target.y() + ", " + (int) target.z() + ")"));
}
}
@@ -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 BindLinkingBookPayload(boolean offhand, String title) implements CustomPacketPayload {
public static final Type<BindLinkingBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_linking_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, BindLinkingBookPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, BindLinkingBookPayload::offhand,
ByteBufCodecs.stringUtf8(32), BindLinkingBookPayload::title,
BindLinkingBookPayload::new);
@Override
public Type<BindLinkingBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,57 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
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.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;
/** Server-authoritative handheld Linking Book binding and travel. */
public final class PortBookNetworking {
public static final int MAX_BOOK_TITLE_LENGTH = 32;
private PortBookNetworking() {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) bind(player, payload);
});
registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player) use(player, payload);
});
}
private static void bind(ServerPlayer player, BindLinkingBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.LINKING_BOOK.get()) || stack.has(Rivencraft.LINK_BOOK_TARGET.get())) return;
String title = payload.title().strip();
if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return;
stack.set(Rivencraft.LINK_BOOK_TARGET.get(), LinkBookTarget.at(player));
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 Linking Book: " + title));
player.containerMenu.broadcastChanges();
}
private static void use(ServerPlayer player, UseLinkingBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.LINKING_BOOK.get())) return;
String failure = LinkingBookItem.travel(player, stack.get(Rivencraft.LINK_BOOK_TARGET.get()));
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
}
private static InteractionHand hand(boolean offhand) {
return offhand ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
}
}
@@ -0,0 +1,20 @@
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 UseLinkingBookPayload(boolean offhand) implements CustomPacketPayload {
public static final Type<UseLinkingBookPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_linking_book"));
public static final StreamCodec<RegistryFriendlyByteBuf, UseLinkingBookPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, UseLinkingBookPayload::offhand, UseLinkingBookPayload::new);
@Override
public Type<UseLinkingBookPayload> type() {
return TYPE;
}
}
@@ -0,0 +1,8 @@
# The 26.3 server has no public runtime-Level insertion API. Keep the
# access surface restricted to the three fields used by RuntimeAgeBackend.
public net.minecraft.server.MinecraftServer levels
public net.minecraft.server.MinecraftServer storageSource
public net.minecraft.server.MinecraftServer executor
# Minecraft 26.3 may expose an immutable level catalog. Replace the field
# atomically when the server announces new runtime Ages instead of mutating it.
public net.minecraft.client.multiplayer.ClientPacketListener levels
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:block/crystal_block"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/crystal_shard"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/descriptive_book"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/linking_book_bound"
}
}
@@ -0,0 +1,6 @@
{
"item.rivencraft.crystal_shard": "Crystal Shard",
"item.rivencraft.descriptive_book": "Descriptive Book",
"item.rivencraft.linking_book": "Linking Book",
"block.rivencraft.crystal_block": "Crystal Block"
}
@@ -0,0 +1,26 @@
license="${mod_license}"
issueTrackerURL="https://git.paddedcell.org/dnorberg/rivencraft/issues"
[[mods]]
modId="${mod_id}"
version="${mod_version}"
displayName="${mod_name} 26.3 Port Preview"
displayURL="https://git.paddedcell.org/dnorberg/rivencraft"
authors="dnorberg and contributors"
description='''
Incomplete Minecraft 26.3 development slice. Not for gameplay or distribution.
'''
[[dependencies.${mod_id}]]
modId="neoforge"
type="required"
versionRange="[${neo_version},)"
ordering="NONE"
side="BOTH"
[[dependencies.${mod_id}]]
modId="minecraft"
type="required"
versionRange="${minecraft_version_range}"
ordering="NONE"
side="BOTH"