Create persistent runtime Ages
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-21 14:42:53 -04:00
1 parent a17083309d
commit 1460e3e4bd
17 files changed
+415 -95

No files matched your search

@@ -9,6 +9,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.event.BookStandConversionHandler;
import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
@@ -22,6 +23,7 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger();
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
AgeDimensionService.register();
modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC);
modEventBus.addListener(ModNetworking::register);
ModDataComponents.register(modEventBus);
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.book;
import java.util.Objects;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceLocation;
/** Immutable, server-owned generation definition for one Age. */
@@ -12,7 +13,9 @@ public record AgeDefinition(
long seed,
ResourceLocation generationProfile,
int generationVersion,
long createdGameTime
long createdGameTime,
ResourceLocation dimensionId,
BlockPos spawn
) {
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "random_vanilla"
@@ -23,5 +26,15 @@ public record AgeDefinition(
Objects.requireNonNull(id, "id");
Objects.requireNonNull(name, "name");
Objects.requireNonNull(generationProfile, "generationProfile");
Objects.requireNonNull(dimensionId, "dimensionId");
}
public static ResourceLocation dimensionId(UUID ageId) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + ageId);
}
public AgeDefinition withSpawn(BlockPos spawn) {
return new AgeDefinition(id, name, seed, generationProfile, generationVersion,
createdGameTime, dimensionId, spawn.immutable());
}
}
@@ -0,0 +1,171 @@
package net.dnorberg.rivencraft.book;
import dev.galacticraft.dynamicdimensions.api.DynamicDimensionRegistry;
import dev.galacticraft.dynamicdimensions.api.event.DynamicDimensionLoadCallback;
import java.util.Optional;
import java.util.OptionalLong;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.util.valueproviders.UniformInt;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.levelgen.Heightmap;
/** Creates, reloads, and resolves Rivencraft's runtime Age dimensions. */
public final class AgeDimensionService {
private static final int PLATFORM_RADIUS = 2;
private static final int SURFACE_SEARCH_RADIUS = 16;
private static final int FALLBACK_PLATFORM_Y = 63;
private AgeDimensionService() {
}
public static void register() {
DynamicDimensionLoadCallback.register((server, loader) -> {
ServerLevel overworld = server.overworld();
for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) {
loader.loadDynamicDimension(
age.dimensionId(),
overworld.getChunkSource().getGenerator(),
createDimensionType()
);
}
});
}
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference) {
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(player.serverLevel(), reference);
ServerLevel level = ensureLoaded(player.getServer(), definition);
if (level == null) {
return Optional.empty();
}
BlockPos spawn = definition.spawn();
if (spawn == null) {
spawn = createSafeSpawn(level);
definition = AgeRegistrySavedData.get(level).setSpawn(definition.id(), spawn);
}
return Optional.of(new LinkTarget(
GlobalPos.of(dimensionKey(definition), spawn),
level.getSharedSpawnAngle(),
0.0F
));
}
public static ServerLevel ensureLoaded(MinecraftServer server, AgeDefinition definition) {
ResourceKey<Level> key = dimensionKey(definition);
ServerLevel existing = server.getLevel(key);
if (existing != null) {
return existing;
}
ServerLevel overworld = server.overworld();
ServerLevel loaded = DynamicDimensionRegistry.from(server).loadDynamicDimension(
definition.dimensionId(),
overworld.getChunkSource().getGenerator(),
createDimensionType()
);
if (loaded == null) {
Rivencraft.LOGGER.error("Failed to load Age {} as {}", definition.id(), definition.dimensionId());
}
return loaded;
}
public static ResourceKey<Level> dimensionKey(AgeDefinition definition) {
return ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, definition.dimensionId());
}
private static BlockPos createSafeSpawn(ServerLevel level) {
BlockPos floorCenter = isNetherStyle(level)
? netherSpawnFloor(level)
: highestFootedSurface(level).orElseGet(() -> fallbackSpawnFloor(level));
for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) {
for (int z = -PLATFORM_RADIUS; z <= PLATFORM_RADIUS; z++) {
BlockPos floor = floorCenter.offset(x, 0, z);
level.setBlock(floor, Blocks.COBBLESTONE.defaultBlockState(), 2);
for (int y = 1; y <= 4; y++) {
level.setBlock(floor.above(y), Blocks.AIR.defaultBlockState(), 2);
}
}
}
BlockPos spawn = floorCenter.above();
level.setDefaultSpawnPos(spawn, 0.0F);
return spawn;
}
private static boolean isNetherStyle(ServerLevel level) {
DimensionType type = level.dimensionType();
return type.ultraWarm() || type.effectsLocation().equals(BuiltinDimensionTypes.NETHER_EFFECTS);
}
private static BlockPos netherSpawnFloor(ServerLevel level) {
int surfaceY = level.getHeight(Heightmap.Types.WORLD_SURFACE, 0, 0);
int floorY = Math.clamp(surfaceY - 1, level.getMinBuildHeight(), level.getMaxBuildHeight() - 5);
return new BlockPos(0, floorY, 0);
}
/** Selects the highest nearby terrain column that can naturally support a player. */
private static Optional<BlockPos> highestFootedSurface(ServerLevel level) {
BlockPos best = null;
int maximumFloorY = level.getMaxBuildHeight() - 5;
for (int x = -SURFACE_SEARCH_RADIUS; x <= SURFACE_SEARCH_RADIUS; x++) {
for (int z = -SURFACE_SEARCH_RADIUS; z <= SURFACE_SEARCH_RADIUS; z++) {
int floorY = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1;
if (floorY < level.getMinBuildHeight() || floorY > maximumFloorY) {
continue;
}
BlockPos candidate = new BlockPos(x, floorY, z);
if (!level.getFluidState(candidate).isEmpty()
|| !level.getBlockState(candidate).isFaceSturdy(level, candidate, net.minecraft.core.Direction.UP)) {
continue;
}
if (best == null || candidate.getY() > best.getY()
|| (candidate.getY() == best.getY()
&& candidate.distSqr(BlockPos.ZERO) < best.distSqr(BlockPos.ZERO))) {
best = candidate;
}
}
}
return Optional.ofNullable(best);
}
private static BlockPos fallbackSpawnFloor(ServerLevel level) {
int floorY = Math.clamp(FALLBACK_PLATFORM_Y,
level.getMinBuildHeight(), level.getMaxBuildHeight() - 5);
return new BlockPos(0, floorY, 0);
}
private static DimensionType createDimensionType() {
return new DimensionType(
OptionalLong.empty(),
true,
false,
false,
true,
1.0D,
false,
false,
-64,
384,
384,
BlockTags.INFINIBURN_OVERWORLD,
BuiltinDimensionTypes.OVERWORLD_EFFECTS,
0.0F,
new DimensionType.MonsterSettings(false, true, UniformInt.of(0, 7), 0)
);
}
}
@@ -4,6 +4,8 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
@@ -22,6 +24,10 @@ public final class AgeRegistrySavedData extends SavedData {
private static final String PROFILE_TAG = "GenerationProfile";
private static final String VERSION_TAG = "GenerationVersion";
private static final String CREATED_TIME_TAG = "CreatedGameTime";
private static final String DIMENSION_TAG = "Dimension";
private static final String SPAWN_X_TAG = "SpawnX";
private static final String SPAWN_Y_TAG = "SpawnY";
private static final String SPAWN_Z_TAG = "SpawnZ";
private static final Factory<AgeRegistrySavedData> FACTORY = new Factory<>(
AgeRegistrySavedData::new,
AgeRegistrySavedData::load,
@@ -44,6 +50,21 @@ public final class AgeRegistrySavedData extends SavedData {
return Optional.ofNullable(ages.get(ageId));
}
public List<AgeDefinition> definitions() {
return List.copyOf(ages.values());
}
public AgeDefinition setSpawn(UUID ageId, BlockPos spawn) {
AgeDefinition existing = ages.get(ageId);
if (existing == null) {
throw new IllegalArgumentException("Unknown Age " + ageId);
}
AgeDefinition updated = existing.withSpawn(spawn);
ages.put(ageId, updated);
setDirty();
return updated;
}
private AgeDefinition getOrCreate(AgeReference reference, long worldSeed, long createdGameTime) {
AgeDefinition existing = ages.get(reference.id());
if (existing != null) {
@@ -56,7 +77,9 @@ public final class AgeRegistrySavedData extends SavedData {
ageSeed(worldSeed, reference.id()),
AgeDefinition.RANDOM_VANILLA_PROFILE,
AgeDefinition.CURRENT_GENERATION_VERSION,
createdGameTime
createdGameTime,
AgeDefinition.dimensionId(reference.id()),
null
);
ages.put(created.id(), created);
setDirty();
@@ -76,13 +99,22 @@ public final class AgeRegistrySavedData extends SavedData {
profile = AgeDefinition.RANDOM_VANILLA_PROFILE;
}
UUID id = entry.getUUID(ID_TAG);
ResourceLocation dimensionId = ResourceLocation.tryParse(entry.getString(DIMENSION_TAG));
if (dimensionId == null) {
dimensionId = AgeDefinition.dimensionId(id);
}
BlockPos spawn = entry.contains(SPAWN_Y_TAG)
? new BlockPos(entry.getInt(SPAWN_X_TAG), entry.getInt(SPAWN_Y_TAG), entry.getInt(SPAWN_Z_TAG))
: null;
data.ages.put(id, new AgeDefinition(
id,
normalizedName(entry.getString(NAME_TAG)),
entry.getLong(SEED_TAG),
profile,
entry.contains(VERSION_TAG) ? entry.getInt(VERSION_TAG) : 1,
entry.getLong(CREATED_TIME_TAG)
entry.getLong(CREATED_TIME_TAG),
dimensionId,
spawn
));
}
return data;
@@ -101,6 +133,12 @@ public final class AgeRegistrySavedData extends SavedData {
entry.putString(PROFILE_TAG, age.generationProfile().toString());
entry.putInt(VERSION_TAG, age.generationVersion());
entry.putLong(CREATED_TIME_TAG, age.createdGameTime());
entry.putString(DIMENSION_TAG, age.dimensionId().toString());
if (age.spawn() != null) {
entry.putInt(SPAWN_X_TAG, age.spawn().getX());
entry.putInt(SPAWN_Y_TAG, age.spawn().getY());
entry.putInt(SPAWN_Z_TAG, age.spawn().getZ());
}
serializedAges.add(entry);
});
tag.put(AGES_TAG, serializedAges);
@@ -113,15 +151,8 @@ public final class AgeRegistrySavedData extends SavedData {
}
private static long ageSeed(long worldSeed, UUID ageId) {
long value = worldSeed ^ ageId.getMostSignificantBits();
value = mix64(value + ageId.getLeastSignificantBits());
return mix64(value ^ 0x524956454E435241L);
}
/** SplitMix64 finalizer, used only to derive a stable seed from world and Age identities. */
private static long mix64(long value) {
value = (value ^ (value >>> 30)) * 0xBF58476D1CE4E5B9L;
value = (value ^ (value >>> 27)) * 0x94D049BB133111EBL;
return value ^ (value >>> 31);
// Dynamic Dimensions AR derives each level seed from the primary world
// seed and the dimension key. Persist the same value as Age metadata.
return worldSeed + AgeDefinition.dimensionId(ageId).hashCode();
}
}
@@ -1,55 +0,0 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import net.minecraft.core.GlobalPos;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.Level;
/**
* Temporary Milestone 2 destination resolver.
*
* <p>Until Ages own generated dimensions, an Age UUID deterministically selects
* one of the three vanilla dimensions. Milestone 3 will replace this with the
* persistent Age-to-dimension mapping without changing book identities.</p>
*/
public final class VanillaAgeResolver {
private static final List<ResourceKey<Level>> VANILLA_DIMENSIONS = List.of(
Level.OVERWORLD,
Level.NETHER,
Level.END
);
private static final List<AgeReference> DEMO_AGES = List.of(
new AgeReference(new UUID(0L, 0L), "Overworld Age"),
new AgeReference(new UUID(0L, 1L), "Nether Age"),
new AgeReference(new UUID(0L, 2L), "End Age")
);
private VanillaAgeResolver() {
}
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference age) {
// Milestone 3 owns Age identity and generation data even while this
// temporary adapter continues to provide a vanilla travel destination.
AgeRegistrySavedData.resolveOrCreate(player.serverLevel(), age);
ResourceKey<Level> dimension = VANILLA_DIMENSIONS.get(Math.floorMod(age.id().hashCode(), VANILLA_DIMENSIONS.size()));
ServerLevel level = player.getServer().getLevel(dimension);
if (level == null) {
return Optional.empty();
}
return Optional.of(new LinkTarget(
GlobalPos.of(dimension, level.getSharedSpawnPos()),
level.getSharedSpawnAngle(),
0.0F
));
}
/** Written sample books exposed in the creative tab until the writing system exists. */
public static List<AgeReference> demoAges() {
return DEMO_AGES;
}
}
@@ -2,6 +2,8 @@ package net.dnorberg.rivencraft.client.screen;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload;
import net.dnorberg.rivencraft.network.BindDescriptiveBookPayload;
import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.network.RequestLinkPreviewPayload;
import net.dnorberg.rivencraft.network.LinkPreviewPayload;
import net.dnorberg.rivencraft.registry.ModDataComponents;
@@ -10,6 +12,8 @@ import net.dnorberg.rivencraft.client.preview.PonderDestinationPreviewRenderer;
import net.minecraft.core.BlockPos;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.FormattedCharSequence;
@@ -35,6 +39,7 @@ public final class DescriptiveBookScreen extends Screen {
private final InteractionHand hand;
private final BlockPos bookStandPos;
private DestinationPreviewRenderer preview;
private EditBox titleInput;
public DescriptiveBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
@@ -52,7 +57,23 @@ public final class DescriptiveBookScreen extends Screen {
@Override
protected void init() {
if (age != null) {
int left = (width - BOOK_WIDTH) / 2;
int top = (height - BOOK_HEIGHT) / 2;
if (age == null) {
int rightPage = left + 177;
titleInput = new EditBox(font, rightPage, top + 69, 124, 20,
Component.translatable("screen.rivencraft.age.title"));
titleInput.setMaxLength(ModNetworking.MAX_BOOK_TITLE_LENGTH);
titleInput.setHint(Component.translatable("screen.rivencraft.age.title_hint"));
addRenderableWidget(titleInput);
setInitialFocus(titleInput);
addRenderableWidget(Button.builder(Component.translatable("screen.rivencraft.age.create"), button -> createAge())
.bounds(rightPage, top + 100, 59, 20)
.build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(rightPage + 65, top + 100, 59, 20)
.build());
} else {
preview = new PonderDestinationPreviewRenderer();
PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
true,
@@ -83,7 +104,7 @@ public final class DescriptiveBookScreen extends Screen {
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT);
if (age == null) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.book.unwritten"),
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"),
left + 177, top + 45, 124);
} else {
preview.render(graphics, left + LINK_PANEL_X, top + LINK_PANEL_Y, LINK_PANEL_WIDTH, LINK_PANEL_HEIGHT, partialTick);
@@ -129,6 +150,20 @@ public final class DescriptiveBookScreen extends Screen {
onClose();
}
private void createAge() {
String name = titleInput.getValue().strip();
if (name.isEmpty()) {
return;
}
PacketDistributor.sendToServer(new BindDescriptiveBookPayload(
bookStandPos != null,
bookStandPos == null && hand == InteractionHand.OFF_HAND,
bookStandPos == null ? BlockPos.ZERO : bookStandPos,
name
));
onClose();
}
private void drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth) {
for (FormattedCharSequence line : font.split(text, maxWidth)) {
graphics.drawString(font, line, x, y, BOOK_TEXT_COLOR, false);
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.LinkTarget;
@@ -102,6 +103,25 @@ public final class LinkingGameTests {
helper.succeed();
}
@GameTest(template = "empty", timeoutTicks = 100)
public static void descriptiveBookAgeCreatesRuntimeDimensionAndSafeSpawn(GameTestHelper helper) {
ServerPlayer player = helper.makeMockServerPlayerInLevel();
AgeReference reference = new AgeReference(UUID.randomUUID(), "Generated Test Age");
LinkTarget target = AgeDimensionService.resolve(player, reference).orElse(null);
helper.assertTrue(target != null, "A page-free Descriptive Book must create a runtime Age destination");
helper.runAfterDelay(1, () -> {
var ageLevel = player.getServer().getLevel(target.location().dimension());
helper.assertTrue(ageLevel != null, "The generated Age must be registered with the server");
BlockPos spawn = target.location().pos();
helper.assertTrue(ageLevel.getBlockState(spawn.below()).is(Blocks.COBBLESTONE),
"The generated Age must have a cobblestone arrival platform");
helper.assertTrue(ageLevel.getBlockState(spawn).isAir() && ageLevel.getBlockState(spawn.above()).isAir(),
"The generated Age arrival must have clear headroom");
helper.succeed();
});
}
@GameTest(template = "empty")
public static void travelUsesLeafyDestinationWithoutFloor(GameTestHelper helper) {
helper.setBlock(ORIGIN.below(), Blocks.AIR);
@@ -5,7 +5,7 @@ import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeVisitSavedData;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.book.VanillaAgeResolver;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
@@ -45,7 +45,7 @@ public final class DescriptiveBookItem extends Item {
}
LinkTarget returnTarget = LinkTarget.from(player);
LinkTarget ageTarget = VanillaAgeResolver.resolve(player, age).orElse(null);
LinkTarget ageTarget = AgeDimensionService.resolve(player, age).orElse(null);
if (ageTarget == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
return false;
@@ -0,0 +1,29 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
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.ResourceLocation;
/** Requests creation of a randomly generated Age for an unwritten Descriptive Book. */
public record BindDescriptiveBookPayload(boolean fromStand, boolean offhand, BlockPos standPos, String title)
implements CustomPacketPayload {
public static final Type<BindDescriptiveBookPayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_descriptive_book")
);
public static final StreamCodec<RegistryFriendlyByteBuf, BindDescriptiveBookPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, BindDescriptiveBookPayload::fromStand,
ByteBufCodecs.BOOL, BindDescriptiveBookPayload::offhand,
BlockPos.STREAM_CODEC, BindDescriptiveBookPayload::standPos,
ByteBufCodecs.STRING_UTF8, BindDescriptiveBookPayload::title,
BindDescriptiveBookPayload::new
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -2,7 +2,7 @@ package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.VanillaAgeResolver;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
@@ -26,6 +26,7 @@ import net.neoforged.neoforge.network.registration.PayloadRegistrar;
import net.neoforged.neoforge.network.PacketDistributor;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public final class ModNetworking {
public static final int MAX_BOOK_TITLE_LENGTH = 32;
@@ -34,8 +35,10 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("5");
PayloadRegistrar registrar = event.registrar("6");
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, ModNetworking::bindBook);
registrar.playToServer(BindDescriptiveBookPayload.TYPE, BindDescriptiveBookPayload.STREAM_CODEC,
ModNetworking::bindDescriptiveBook);
registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC, ModNetworking::useBook);
registrar.playToServer(UseDescriptiveBookPayload.TYPE, UseDescriptiveBookPayload.STREAM_CODEC,
ModNetworking::useDescriptiveBook);
@@ -46,6 +49,33 @@ public final class ModNetworking {
ClientLinkPreviewHandler::handle);
}
private static void bindDescriptiveBook(BindDescriptiveBookPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) {
return;
}
BookAccess access = descriptiveBookAccess(player, payload.fromStand(), payload.offhand(), payload.standPos());
if (access == null || access.stack().has(ModDataComponents.AGE_REFERENCE.get())) {
return;
}
String title = normalizeTitle(payload.title());
if (title.isEmpty()) {
return;
}
AgeReference reference = new AgeReference(UUID.randomUUID(), title);
if (AgeDimensionService.resolve(player, reference).isEmpty()) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
return;
}
access.stack().set(ModDataComponents.AGE_REFERENCE.get(), reference);
access.stack().set(DataComponents.CUSTOM_NAME, Component.literal(title));
access.sync().run();
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F);
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.created", title), true);
}
private static void bindBook(BindLinkingBookPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) {
return;
@@ -131,7 +161,7 @@ public final class ModNetworking {
LinkTarget target;
if (payload.descriptiveBook() && stack.is(ModItems.DESCRIPTIVE_BOOK.get())) {
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
target = age == null ? null : VanillaAgeResolver.resolve(player, age).orElse(null);
target = age == null ? null : AgeDimensionService.resolve(player, age).orElse(null);
} else if (!payload.descriptiveBook() && stack.is(ModItems.LINKING_BOOK.get())) {
target = stack.get(ModDataComponents.LINK_TARGET.get());
} else {
@@ -186,4 +216,25 @@ public final class ModNetworking {
? normalized
: normalized.substring(0, MAX_BOOK_TITLE_LENGTH).stripTrailing();
}
private static BookAccess descriptiveBookAccess(
ServerPlayer player,
boolean fromStand,
boolean offhand,
BlockPos standPos
) {
if (fromStand) {
if (player.distanceToSqr(Vec3.atCenterOf(standPos)) > 64.0D
|| !(player.level().getBlockEntity(standPos) instanceof BookStandBlockEntity stand)
|| !stand.getBook().is(ModItems.DESCRIPTIVE_BOOK.get())) {
return null;
}
return new BookAccess(stand.getBook(), () -> stand.setBook(stand.getBook()));
}
ItemStack stack = player.getItemInHand(hand(offhand));
return stack.is(ModItems.DESCRIPTIVE_BOOK.get()) ? new BookAccess(stack, () -> { }) : null;
}
private record BookAccess(ItemStack stack, Runnable sync) {
}
}
@@ -1,12 +1,9 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.VanillaAgeResolver;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.ItemStack;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
@@ -21,12 +18,6 @@ public final class ModCreativeTabs {
.icon(() -> ModItems.DESCRIPTIVE_BOOK.get().getDefaultInstance())
.displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
VanillaAgeResolver.demoAges().forEach(age -> {
ItemStack book = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
book.set(ModDataComponents.AGE_REFERENCE.get(), age);
book.set(DataComponents.CUSTOM_NAME, Component.literal(age.name()));
output.accept(book);
});
output.accept(ModItems.LINKING_BOOK.get());
})
.build());
@@ -13,6 +13,7 @@
"message.rivencraft.linking_book.missing_dimension": "That dimension is unavailable",
"message.rivencraft.linking_book.unsafe": "No safe destination was found near the link",
"message.rivencraft.descriptive_book.unwritten": "This Descriptive Book is unwritten",
"message.rivencraft.descriptive_book.created": "Created Age: %s",
"message.rivencraft.descriptive_book.unavailable": "That Age is unavailable",
"message.rivencraft.descriptive_book.unsafe": "No safe destination was found in that Age",
"message.rivencraft.descriptive_book.linked_first": "Linked to %s — a Return Link was created",
@@ -23,6 +24,10 @@
"screen.rivencraft.book.bind": "Bind",
"screen.rivencraft.book.link": "Link",
"screen.rivencraft.book.unwritten": "This Descriptive Book is unwritten",
"screen.rivencraft.age.title": "Age name",
"screen.rivencraft.age.title_hint": "Name this Age",
"screen.rivencraft.age.name_prompt": "Name this Age before writing the Descriptive Book",
"screen.rivencraft.age.create": "Create",
"screen.rivencraft.book.preview_pending": "Loading destination...",
"screen.rivencraft.book.click_to_link": "Click the panel to link",
"itemGroup.rivencraft": "Rivencraft"
@@ -93,6 +93,13 @@ A spiritual successor and modern port of Mystcraft for Minecraft 1.21.1 on NeoFo
ordering="AFTER"
side="CLIENT"
[[dependencies.${mod_id}]]
modId="dynamicdimensions"
type="required"
versionRange="[0.9.1,1.0.0)"
ordering="AFTER"
side="BOTH"
# Features are specific properties of the game environment, that you may want to declare you require. This example declares
# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't
# stop your mod loading on the server for example.