Configure descriptive book return links
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-21 10:01:23 -04:00
1 parent 3760b0dea3
commit b1d563fb84
9 files changed
+146 -8

No files matched your search

+5 -1
View File
@@ -36,10 +36,14 @@ independently validates every link.
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. Descriptive
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.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
## Requirements
- A Java 21 JDK
+6
View File
@@ -168,6 +168,12 @@ End destinations instead of their inherited Overworld biome sky tint.
Version 0.2.6 expands destination snapshots to a 32-by-32-by-32 cube while
keeping the existing target-centered Ponder camera position.
Version 0.2.7 persists each Age's first successful visit and creates its
Return Link only for that initial arrival.
Version 0.2.8 makes Return Link creation a server setting: `ON_CREATION`
(default), `ALWAYS`, or `DISABLED`.
The preview code is separated behind a renderer interface and uses an isolated
Ponder level populated from server-authoritative destination snapshots.
+1 -1
View File
@@ -33,7 +33,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. See https://semver.org/
mod_version=0.2.6
mod_version=0.2.8
# 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
@@ -8,8 +8,11 @@ import net.dnorberg.rivencraft.registry.ModBlocks;
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.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.config.ModConfig;
import net.neoforged.neoforge.common.NeoForge;
import org.slf4j.Logger;
@@ -18,7 +21,8 @@ public final class Rivencraft {
public static final String MOD_ID = "rivencraft";
public static final Logger LOGGER = LogUtils.getLogger();
public Rivencraft(IEventBus modEventBus) {
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC);
modEventBus.addListener(ModNetworking::register);
ModDataComponents.register(modEventBus);
ModBlocks.register(modEventBus);
@@ -0,0 +1,61 @@
package net.dnorberg.rivencraft.book;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
/** World-persistent record of Ages whose initial Return Link has been created. */
public final class AgeVisitSavedData extends SavedData {
private static final String DATA_NAME = "rivencraft_age_visits";
private static final String VISITED_AGES_TAG = "VisitedAges";
private static final String AGE_ID_TAG = "AgeId";
private static final Factory<AgeVisitSavedData> FACTORY = new Factory<>(
AgeVisitSavedData::new,
AgeVisitSavedData::load,
DataFixTypes.SAVED_DATA_MAP_DATA
);
private final Set<UUID> visitedAges = new HashSet<>();
public static AgeVisitSavedData get(ServerLevel level) {
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
}
/** Returns true exactly once for each Age UUID in a world. */
public boolean markFirstVisit(UUID ageId) {
if (!visitedAges.add(ageId)) {
return false;
}
setDirty();
return true;
}
private static AgeVisitSavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeVisitSavedData data = new AgeVisitSavedData();
for (int index = 0; index < tag.getList(VISITED_AGES_TAG, CompoundTag.TAG_COMPOUND).size(); index++) {
CompoundTag entry = tag.getList(VISITED_AGES_TAG, CompoundTag.TAG_COMPOUND).getCompound(index);
if (entry.hasUUID(AGE_ID_TAG)) {
data.visitedAges.add(entry.getUUID(AGE_ID_TAG));
}
}
return data;
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
ListTag ages = new ListTag();
for (UUID ageId : visitedAges) {
CompoundTag entry = new CompoundTag();
entry.putUUID(AGE_ID_TAG, ageId);
ages.add(entry);
}
tag.put(VISITED_AGES_TAG, ages);
return tag;
}
}
@@ -0,0 +1,37 @@
package net.dnorberg.rivencraft.config;
import net.neoforged.neoforge.common.ModConfigSpec;
/** Server-configurable gameplay policies. */
public final class RivencraftConfig {
public enum ReturnLinkMode {
/** Create one Return Link when an Age is first reached. */
ON_CREATION,
/** Create a Return Link on every successful Descriptive Book arrival. */
ALWAYS,
/** Never create Return Links automatically. */
DISABLED
}
public static final ModConfigSpec SPEC;
private static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
static {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
builder.push("descriptive_books");
RETURN_LINK_MODE = builder.comment(
"Controls automatic Return Link creation for Descriptive Book travel.",
"ON_CREATION creates one link when an Age is first reached; ALWAYS creates one on every arrival; DISABLED creates none."
)
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
builder.pop();
SPEC = builder.build();
}
private RivencraftConfig() {
}
public static ReturnLinkMode returnLinkMode() {
return RETURN_LINK_MODE.get();
}
}
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.LinkingService;
import net.dnorberg.rivencraft.book.AgeVisitSavedData;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
@@ -16,6 +17,7 @@ import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
import java.util.UUID;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@@ -63,6 +65,16 @@ public final class LinkingGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void ageVisitCreatesOnlyOneReturnLink(GameTestHelper helper) {
AgeVisitSavedData visits = AgeVisitSavedData.get(helper.getLevel());
UUID ageId = UUID.randomUUID();
helper.assertTrue(visits.markFirstVisit(ageId), "The first Age visit must create a Return Link");
helper.assertFalse(visits.markFirstVisit(ageId), "Later visits to the same Age must not create another Return Link");
helper.succeed();
}
@GameTest(template = "empty")
public static void travelUsesLeafyDestinationWithoutFloor(GameTestHelper helper) {
helper.setBlock(ORIGIN.below(), Blocks.AIR);
@@ -2,9 +2,11 @@ package net.dnorberg.rivencraft.item;
import java.util.List;
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.config.RivencraftConfig;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.ChatFormatting;
@@ -55,12 +57,23 @@ public final class DescriptiveBookItem extends Item {
return false;
}
ItemStack returnBook = createReturnBook(returnTarget);
if (!player.getInventory().add(returnBook)) {
player.drop(returnBook, false);
boolean createReturnLink = switch (RivencraftConfig.returnLinkMode()) {
case ON_CREATION -> AgeVisitSavedData.get((net.minecraft.server.level.ServerLevel) player.level())
.markFirstVisit(age.id());
case ALWAYS -> true;
case DISABLED -> false;
};
if (createReturnLink) {
ItemStack returnBook = createReturnBook(returnTarget);
if (!player.getInventory().add(returnBook)) {
player.drop(returnBook, false);
}
}
player.level().playSound(null, player.blockPosition(), SoundEvents.PORTAL_TRAVEL, SoundSource.PLAYERS, 0.35F, 1.0F);
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.linked", age.name()), true);
player.displayClientMessage(Component.translatable(
createReturnLink ? "message.rivencraft.descriptive_book.linked_first" : "message.rivencraft.descriptive_book.linked",
age.name()
), true);
return true;
}
@@ -15,7 +15,8 @@
"message.rivencraft.descriptive_book.unwritten": "This Descriptive Book is unwritten",
"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": "Linked to %s — a Return Link was created",
"message.rivencraft.descriptive_book.linked_first": "Linked to %s — a Return Link was created",
"message.rivencraft.descriptive_book.linked": "Linked to %s",
"screen.rivencraft.book.title": "Book title",
"screen.rivencraft.book.title_hint": "Name this Linking Book",
"screen.rivencraft.book.name_prompt": "Name the book before binding it to this location",