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. 1.21.1 on NeoForge.
The bootstrap and books/linking milestones are complete. The project currently The bootstrap and books/linking milestones are complete. The project currently
provides functional Linking Books and temporary vanilla-dimension Descriptive provides functional Linking Books and randomly seeded, persistent vanilla-like
Book destinations with generated Return Links. Age generation, symbols, Ages created from page-free Descriptive Books. Symbols, instability, and the
instability, and writing mechanics have not been ported yet. full writing mechanics have not been ported yet.
## Linking Book ## Linking Book
@@ -33,13 +33,16 @@ independently validates every link.
## Descriptive Book ## Descriptive Book
Until generated Ages arrive in Milestone 3, written Descriptive Books resolve Open a page-free Descriptive Book, name its Age, and press Create. Rivencraft
deterministically to the Overworld, Nether, or End according to their Age UUID. assigns the book a permanent Age UUID, creates a dedicated vanilla-like runtime
The Rivencraft creative tab includes one sample book for each. Linking creates dimension, and prepares a five-by-five cobblestone arrival platform at the
a bound Return Link to the exact source position and orientation on an Age's highest suitable nearby terrain. If no sturdy surface is available, the
first successful visit. Descriptive platform is generated at Y=63. Reopen the book and click its preview to travel.
Book destinations additionally require solid footing beneath the player. If Copies of the written book continue to resolve to the same persistent Age
their recorded arrival is unsafe, they search nearby terrain for a footed spot. 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 owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`. 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 - Minecraft 1.21.1
- NeoForge 21.1.251 or newer in the 21.1 line - NeoForge 21.1.251 or newer in the 21.1 line
- Ponder 1.0.82 for Minecraft 1.21.1 (client) - 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 ## Build and run
+7
View File
@@ -28,6 +28,11 @@ sourceSets.main.resources {
repositories { repositories {
maven { url = 'https://maven.createmod.net' } maven { url = 'https://maven.createmod.net' }
maven {
url = 'https://www.cursemaven.com'
content { includeGroup 'curse.maven' }
}
maven { url = 'https://maven.bai.lol' }
} }
base { base {
@@ -117,6 +122,8 @@ configurations {
dependencies { dependencies {
implementation "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}" 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 // Example optional mod dependency with JEI
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime // 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}" // 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 time. The temporary vanilla-dimension adapter remains in place until runtime
Age dimensions are introduced by a later release slice. 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 The preview code is separated behind a renderer interface and uses an isolated
Ponder level populated from server-authoritative destination snapshots. 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 # The Neo version must agree with the Minecraft version to get a valid artifact
neo_version=21.1.251 neo_version=21.1.251
ponder_version=1.0.82 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 # The loader version range can only use the major version of FML as bounds
loader_version_range=[1,) 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. # 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 mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. # 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. # 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. # This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html # 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.event.BookStandConversionHandler;
import net.dnorberg.rivencraft.network.ModNetworking; import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.config.RivencraftConfig; import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer; import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod; import net.neoforged.fml.common.Mod;
@@ -22,6 +23,7 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger(); public static final Logger LOGGER = LogUtils.getLogger();
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) { public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
AgeDimensionService.register();
modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC); modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC);
modEventBus.addListener(ModNetworking::register); modEventBus.addListener(ModNetworking::register);
ModDataComponents.register(modEventBus); ModDataComponents.register(modEventBus);
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.book;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
/** Immutable, server-owned generation definition for one Age. */ /** Immutable, server-owned generation definition for one Age. */
@@ -12,7 +13,9 @@ public record AgeDefinition(
long seed, long seed,
ResourceLocation generationProfile, ResourceLocation generationProfile,
int generationVersion, int generationVersion,
long createdGameTime long createdGameTime,
ResourceLocation dimensionId,
BlockPos spawn
) { ) {
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath( public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "random_vanilla" Rivencraft.MOD_ID, "random_vanilla"
@@ -23,5 +26,15 @@ public record AgeDefinition(
Objects.requireNonNull(id, "id"); Objects.requireNonNull(id, "id");
Objects.requireNonNull(name, "name"); Objects.requireNonNull(name, "name");
Objects.requireNonNull(generationProfile, "generationProfile"); 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.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup; import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag; 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 PROFILE_TAG = "GenerationProfile";
private static final String VERSION_TAG = "GenerationVersion"; private static final String VERSION_TAG = "GenerationVersion";
private static final String CREATED_TIME_TAG = "CreatedGameTime"; 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<>( private static final Factory<AgeRegistrySavedData> FACTORY = new Factory<>(
AgeRegistrySavedData::new, AgeRegistrySavedData::new,
AgeRegistrySavedData::load, AgeRegistrySavedData::load,
@@ -44,6 +50,21 @@ public final class AgeRegistrySavedData extends SavedData {
return Optional.ofNullable(ages.get(ageId)); 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) { private AgeDefinition getOrCreate(AgeReference reference, long worldSeed, long createdGameTime) {
AgeDefinition existing = ages.get(reference.id()); AgeDefinition existing = ages.get(reference.id());
if (existing != null) { if (existing != null) {
@@ -56,7 +77,9 @@ public final class AgeRegistrySavedData extends SavedData {
ageSeed(worldSeed, reference.id()), ageSeed(worldSeed, reference.id()),
AgeDefinition.RANDOM_VANILLA_PROFILE, AgeDefinition.RANDOM_VANILLA_PROFILE,
AgeDefinition.CURRENT_GENERATION_VERSION, AgeDefinition.CURRENT_GENERATION_VERSION,
createdGameTime createdGameTime,
AgeDefinition.dimensionId(reference.id()),
null
); );
ages.put(created.id(), created); ages.put(created.id(), created);
setDirty(); setDirty();
@@ -76,13 +99,22 @@ public final class AgeRegistrySavedData extends SavedData {
profile = AgeDefinition.RANDOM_VANILLA_PROFILE; profile = AgeDefinition.RANDOM_VANILLA_PROFILE;
} }
UUID id = entry.getUUID(ID_TAG); 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( data.ages.put(id, new AgeDefinition(
id, id,
normalizedName(entry.getString(NAME_TAG)), normalizedName(entry.getString(NAME_TAG)),
entry.getLong(SEED_TAG), entry.getLong(SEED_TAG),
profile, profile,
entry.contains(VERSION_TAG) ? entry.getInt(VERSION_TAG) : 1, entry.contains(VERSION_TAG) ? entry.getInt(VERSION_TAG) : 1,
entry.getLong(CREATED_TIME_TAG) entry.getLong(CREATED_TIME_TAG),
dimensionId,
spawn
)); ));
} }
return data; return data;
@@ -101,6 +133,12 @@ public final class AgeRegistrySavedData extends SavedData {
entry.putString(PROFILE_TAG, age.generationProfile().toString()); entry.putString(PROFILE_TAG, age.generationProfile().toString());
entry.putInt(VERSION_TAG, age.generationVersion()); entry.putInt(VERSION_TAG, age.generationVersion());
entry.putLong(CREATED_TIME_TAG, age.createdGameTime()); 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); serializedAges.add(entry);
}); });
tag.put(AGES_TAG, serializedAges); tag.put(AGES_TAG, serializedAges);
@@ -113,15 +151,8 @@ public final class AgeRegistrySavedData extends SavedData {
} }
private static long ageSeed(long worldSeed, UUID ageId) { private static long ageSeed(long worldSeed, UUID ageId) {
long value = worldSeed ^ ageId.getMostSignificantBits(); // Dynamic Dimensions AR derives each level seed from the primary world
value = mix64(value + ageId.getLeastSignificantBits()); // seed and the dimension key. Persist the same value as Age metadata.
return mix64(value ^ 0x524956454E435241L); return worldSeed + AgeDefinition.dimensionId(ageId).hashCode();
}
/** 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);
} }
} }
@@ -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.book.AgeReference;
import net.dnorberg.rivencraft.network.UseDescriptiveBookPayload; 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.RequestLinkPreviewPayload;
import net.dnorberg.rivencraft.network.LinkPreviewPayload; import net.dnorberg.rivencraft.network.LinkPreviewPayload;
import net.dnorberg.rivencraft.registry.ModDataComponents; 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.core.BlockPos;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen; 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.network.chat.Component;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.FormattedCharSequence; import net.minecraft.util.FormattedCharSequence;
@@ -35,6 +39,7 @@ public final class DescriptiveBookScreen extends Screen {
private final InteractionHand hand; private final InteractionHand hand;
private final BlockPos bookStandPos; private final BlockPos bookStandPos;
private DestinationPreviewRenderer preview; private DestinationPreviewRenderer preview;
private EditBox titleInput;
public DescriptiveBookScreen(InteractionHand hand, ItemStack book) { public DescriptiveBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName()); super(book.getHoverName());
@@ -52,7 +57,23 @@ public final class DescriptiveBookScreen extends Screen {
@Override @Override
protected void init() { 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(); preview = new PonderDestinationPreviewRenderer();
PacketDistributor.sendToServer(new RequestLinkPreviewPayload( PacketDistributor.sendToServer(new RequestLinkPreviewPayload(
true, true,
@@ -83,7 +104,7 @@ public final class DescriptiveBookScreen extends Screen {
TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT); TEXTURE_WIDTH, TEXTURE_HEIGHT, TEXTURE_WIDTH, TEXTURE_HEIGHT);
if (age == null) { 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); left + 177, top + 45, 124);
} else { } else {
preview.render(graphics, left + LINK_PANEL_X, top + LINK_PANEL_Y, LINK_PANEL_WIDTH, LINK_PANEL_HEIGHT, partialTick); 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(); 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) { private void drawWrappedWithoutShadow(GuiGraphics graphics, Component text, int x, int y, int maxWidth) {
for (FormattedCharSequence line : font.split(text, maxWidth)) { for (FormattedCharSequence line : font.split(text, maxWidth)) {
graphics.drawString(font, line, x, y, BOOK_TEXT_COLOR, false); 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.Rivencraft;
import net.dnorberg.rivencraft.book.AgeDefinition; import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData; import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.LinkTarget;
@@ -102,6 +103,25 @@ public final class LinkingGameTests {
helper.succeed(); 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") @GameTest(template = "empty")
public static void travelUsesLeafyDestinationWithoutFloor(GameTestHelper helper) { public static void travelUsesLeafyDestinationWithoutFloor(GameTestHelper helper) {
helper.setBlock(ORIGIN.below(), Blocks.AIR); 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.AgeVisitSavedData;
import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkingService; 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.config.RivencraftConfig;
import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModItems;
@@ -45,7 +45,7 @@ public final class DescriptiveBookItem extends Item {
} }
LinkTarget returnTarget = LinkTarget.from(player); LinkTarget returnTarget = LinkTarget.from(player);
LinkTarget ageTarget = VanillaAgeResolver.resolve(player, age).orElse(null); LinkTarget ageTarget = AgeDimensionService.resolve(player, age).orElse(null);
if (ageTarget == null) { if (ageTarget == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true); player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
return false; 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.LinkTarget;
import net.dnorberg.rivencraft.book.AgeReference; 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.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler; import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler;
import net.dnorberg.rivencraft.item.DescriptiveBookItem; import net.dnorberg.rivencraft.item.DescriptiveBookItem;
@@ -26,6 +26,7 @@ import net.neoforged.neoforge.network.registration.PayloadRegistrar;
import net.neoforged.neoforge.network.PacketDistributor; import net.neoforged.neoforge.network.PacketDistributor;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
public final class ModNetworking { public final class ModNetworking {
public static final int MAX_BOOK_TITLE_LENGTH = 32; public static final int MAX_BOOK_TITLE_LENGTH = 32;
@@ -34,8 +35,10 @@ public final class ModNetworking {
} }
public static void register(RegisterPayloadHandlersEvent event) { 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(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(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC, ModNetworking::useBook);
registrar.playToServer(UseDescriptiveBookPayload.TYPE, UseDescriptiveBookPayload.STREAM_CODEC, registrar.playToServer(UseDescriptiveBookPayload.TYPE, UseDescriptiveBookPayload.STREAM_CODEC,
ModNetworking::useDescriptiveBook); ModNetworking::useDescriptiveBook);
@@ -46,6 +49,33 @@ public final class ModNetworking {
ClientLinkPreviewHandler::handle); 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) { private static void bindBook(BindLinkingBookPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) { if (!(context.player() instanceof ServerPlayer player)) {
return; return;
@@ -131,7 +161,7 @@ public final class ModNetworking {
LinkTarget target; LinkTarget target;
if (payload.descriptiveBook() && stack.is(ModItems.DESCRIPTIVE_BOOK.get())) { if (payload.descriptiveBook() && stack.is(ModItems.DESCRIPTIVE_BOOK.get())) {
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.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())) { } else if (!payload.descriptiveBook() && stack.is(ModItems.LINKING_BOOK.get())) {
target = stack.get(ModDataComponents.LINK_TARGET.get()); target = stack.get(ModDataComponents.LINK_TARGET.get());
} else { } else {
@@ -186,4 +216,25 @@ public final class ModNetworking {
? normalized ? normalized
: normalized.substring(0, MAX_BOOK_TITLE_LENGTH).stripTrailing(); : 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; package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft; 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.core.registries.Registries;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.ItemStack;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredHolder; import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister; import net.neoforged.neoforge.registries.DeferredRegister;
@@ -21,12 +18,6 @@ public final class ModCreativeTabs {
.icon(() -> ModItems.DESCRIPTIVE_BOOK.get().getDefaultInstance()) .icon(() -> ModItems.DESCRIPTIVE_BOOK.get().getDefaultInstance())
.displayItems((parameters, output) -> { .displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get()); 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()); output.accept(ModItems.LINKING_BOOK.get());
}) })
.build()); .build());
@@ -13,6 +13,7 @@
"message.rivencraft.linking_book.missing_dimension": "That dimension is unavailable", "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.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.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.unavailable": "That Age is unavailable",
"message.rivencraft.descriptive_book.unsafe": "No safe destination was found in that Age", "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", "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.bind": "Bind",
"screen.rivencraft.book.link": "Link", "screen.rivencraft.book.link": "Link",
"screen.rivencraft.book.unwritten": "This Descriptive Book is unwritten", "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.preview_pending": "Loading destination...",
"screen.rivencraft.book.click_to_link": "Click the panel to link", "screen.rivencraft.book.click_to_link": "Click the panel to link",
"itemGroup.rivencraft": "Rivencraft" "itemGroup.rivencraft": "Rivencraft"
@@ -93,6 +93,13 @@ A spiritual successor and modern port of Mystcraft for Minecraft 1.21.1 on NeoFo
ordering="AFTER" ordering="AFTER"
side="CLIENT" 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 # 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 # 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. # stop your mod loading on the server for example.