diff --git a/README.md b/README.md index ff4c7ac..2cf63b8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/PORTING.md b/docs/PORTING.md index f5538b5..ae5170c 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -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. diff --git a/gradle.properties b/gradle.properties index c5a7c68..3691096 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index ee3f4ec..7e3a353 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -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); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeVisitSavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeVisitSavedData.java new file mode 100644 index 0000000..a4f63d1 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeVisitSavedData.java @@ -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 FACTORY = new Factory<>( + AgeVisitSavedData::new, + AgeVisitSavedData::load, + DataFixTypes.SAVED_DATA_MAP_DATA + ); + + private final Set 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; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java new file mode 100644 index 0000000..c4f6aee --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java @@ -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 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(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 62bc089..1d3e4f2 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -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); diff --git a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java index af8abbe..2d51269 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java @@ -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; } diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 178db7e..ccd5f99 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -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",