diff --git a/README.md b/README.md index 256fb63..cb3ee5f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.gradle b/build.gradle index b38c381..f5c1da7 100644 --- a/build.gradle +++ b/build.gradle @@ -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}" diff --git a/docs/PORTING.md b/docs/PORTING.md index 69f809b..e6aed97 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -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. diff --git a/gradle.properties b/gradle.properties index 9ab1afa..dfae611 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 7e3a353..4102701 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -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); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java index 365d41f..a2c679c 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java @@ -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()); } } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java new file mode 100644 index 0000000..8edd8cc --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -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 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 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 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 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) + ); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java index bb7f504..c499a44 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java @@ -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 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 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(); } } diff --git a/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java b/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java deleted file mode 100644 index 57bbb02..0000000 --- a/src/main/java/net/dnorberg/rivencraft/book/VanillaAgeResolver.java +++ /dev/null @@ -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. - * - *

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.

- */ -public final class VanillaAgeResolver { - private static final List> VANILLA_DIMENSIONS = List.of( - Level.OVERWORLD, - Level.NETHER, - Level.END - ); - private static final List 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 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 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 demoAges() { - return DEMO_AGES; - } -} diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java index 328a724..56948ef 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java @@ -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); diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index ea5a467..0cb2a5c 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -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); diff --git a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java index 2d51269..a124e38 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java @@ -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; diff --git a/src/main/java/net/dnorberg/rivencraft/network/BindDescriptiveBookPayload.java b/src/main/java/net/dnorberg/rivencraft/network/BindDescriptiveBookPayload.java new file mode 100644 index 0000000..3280641 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/network/BindDescriptiveBookPayload.java @@ -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 TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "bind_descriptive_book") + ); + public static final StreamCodec 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 type() { + return TYPE; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java index 176ac26..3afc0ce 100644 --- a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java +++ b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java @@ -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) { + } } diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index 281ad39..91d4902 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -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()); diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index ccd5f99..3ce73fa 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -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" diff --git a/src/main/templates/META-INF/neoforge.mods.toml b/src/main/templates/META-INF/neoforge.mods.toml index a5c4d97..6a3189f 100644 --- a/src/main/templates/META-INF/neoforge.mods.toml +++ b/src/main/templates/META-INF/neoforge.mods.toml @@ -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.