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

+14 -10
View File
@@ -5,9 +5,9 @@ Rivencraft is a clean, incremental port of the ideas and gameplay of
1.21.1 on NeoForge.
The bootstrap and books/linking milestones are complete. The project currently
provides functional Linking Books and temporary vanilla-dimension Descriptive
Book destinations with generated Return Links. Age generation, symbols,
instability, and writing mechanics have not been ported yet.
provides functional Linking Books and randomly seeded, persistent vanilla-like
Ages created from page-free Descriptive Books. Symbols, instability, and the
full writing mechanics have not been ported yet.
## Linking Book
@@ -33,13 +33,16 @@ independently validates every link.
## Descriptive Book
Until generated Ages arrive in Milestone 3, written Descriptive Books resolve
deterministically to the Overworld, Nether, or End according to their Age UUID.
The Rivencraft creative tab includes one sample book for each. Linking creates
a bound Return Link to the exact source position and orientation on an Age's
first successful visit. Descriptive
Book destinations additionally require solid footing beneath the player. If
their recorded arrival is unsafe, they search nearby terrain for a footed spot.
Open a page-free Descriptive Book, name its Age, and press Create. Rivencraft
assigns the book a permanent Age UUID, creates a dedicated vanilla-like runtime
dimension, and prepares a five-by-five cobblestone arrival platform at the
highest suitable nearby terrain. If no sturdy surface is available, the
platform is generated at Y=63. Reopen the book and click its preview to travel.
Copies of the written book continue to resolve to the same persistent Age
across server restarts.
Linking creates a bound Return Link to the exact source position and orientation
according to the configured Return Link policy.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
@@ -50,6 +53,7 @@ server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
- Minecraft 1.21.1
- NeoForge 21.1.251 or newer in the 21.1 line
- Ponder 1.0.82 for Minecraft 1.21.1 (client)
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
## Build and run
+7
View File
@@ -28,6 +28,11 @@ sourceSets.main.resources {
repositories {
maven { url = 'https://maven.createmod.net' }
maven {
url = 'https://www.cursemaven.com'
content { includeGroup 'curse.maven' }
}
maven { url = 'https://maven.bai.lol' }
}
base {
@@ -117,6 +122,8 @@ configurations {
dependencies {
implementation "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}"
implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}"
implementation "lol.bai:badpackets:neo-${badpackets_version}"
// Example optional mod dependency with JEI
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}"
+7
View File
@@ -190,6 +190,13 @@ generation profile, generation-version marker, canonical name, and creation
time. The temporary vanilla-dimension adapter remains in place until runtime
Age dimensions are introduced by a later release slice.
Version 0.2.10.0 makes page-free Descriptive Books playable. Naming an
unwritten book creates a dedicated, persistent vanilla-like Age through the
Dynamic Dimensions API. Each Age reloads with its server, retains its terrain,
and receives a five-by-five cobblestone platform with cleared arrival headroom.
Non-Nether Ages select the highest sturdy surface near the origin and fall back
to a platform at Y=63 when no suitable natural footing exists.
The preview code is separated behind a renderer interface and uses an isolated
Ponder level populated from server-authoritative destination snapshots.
+3 -1
View File
@@ -20,6 +20,8 @@ minecraft_version_range=[1.21.1]
# The Neo version must agree with the Minecraft version to get a valid artifact
neo_version=21.1.251
ponder_version=1.0.82
dynamic_dimensions_file_id=7707789
badpackets_version=0.8.2
# The loader version range can only use the major version of FML as bounds
loader_version_range=[1,)
@@ -33,7 +35,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.2.9.0
mod_version=0.2.10.0
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
@@ -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.