diff --git a/docs/AGES.md b/docs/AGES.md new file mode 100644 index 0000000..3cb24bd --- /dev/null +++ b/docs/AGES.md @@ -0,0 +1,39 @@ +# Age administration + +Rivencraft owns each Age definition for the lifetime of a world. A definition +contains the Age UUID, canonical name, seed, generation version, runtime +dimension identifier, spawn, and deterministic traits. Definitions are stored +in the Overworld saved data and are never rerolled during ordinary loading. + +## Lifecycle + +An Age definition is created before its runtime dimension. If dimension +creation is interrupted, the saved definition remains authoritative and the +same Age is reconstructed the next time its Descriptive Book is used. + +Registered Ages load when the server starts. A book also loads its destination +on demand when an administrator previously unloaded it. Unloading retains the +definition and world folder, so existing books continue to reference the same +Age and later reload the same terrain. + +Rivencraft does not automatically delete Ages. Safe permanent deletion needs a +complete reference index for books in inventories, containers, Book Stands, +and unloaded chunks. Until that exists, retaining the definition prevents an +administrator action from silently orphaning books. + +Destination-preview chunk loaders are temporary. Rivencraft releases them when +the book closes, the player travels, the player disconnects, the destination +Age is unloaded, or the server stops. + +## Commands + +The commands require permission level 2. + +- `/rivencraft age list` lists every registered Age and whether its runtime + dimension is currently loaded. +- `/rivencraft age unload ` safely unloads one Age. Players in that + Age are handled by Dynamic Dimensions, and its definition and world data are + retained. Use the command suggestions to select a registered dimension. + +Opening a Descriptive Book for an unloaded Age loads it again from its saved +definition. diff --git a/docs/PORTING.md b/docs/PORTING.md index 97b8293..2cbeaac 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -46,6 +46,8 @@ acceptance issue and all required implementation issues are complete. Age references use UUIDs rather than dimension keys. The server-owned Age registry maps each stable identity to its immutable definition and runtime dimension, keeping saved books valid if an Age is migrated or regenerated. +Operational lifecycle and unload behavior are documented in +[AGES.md](AGES.md). The core mod must remain usable without Create, Ponder, or Immersive Portals. The built-in legacy animation is the dependency-free destination preview. diff --git a/gradle.properties b/gradle.properties index 6793bf6..055c082 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,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.26.1 +mod_version=0.2.27.0 immersive_portals_version=6.0.7 # 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. diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 13123f4..f50abbd 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -10,6 +10,8 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities; import net.dnorberg.rivencraft.registry.ModBiomeSources; import net.dnorberg.rivencraft.event.BookStandConversionHandler; import net.dnorberg.rivencraft.event.AgeCelestialAngleSync; +import net.dnorberg.rivencraft.event.AgeLifecycleEvents; +import net.dnorberg.rivencraft.command.RivencraftCommands; import net.dnorberg.rivencraft.network.ModNetworking; import net.dnorberg.rivencraft.config.RivencraftConfig; import net.dnorberg.rivencraft.config.RivencraftClientConfig; @@ -43,6 +45,9 @@ public final class Rivencraft { NeoForge.EVENT_BUS.addListener(AgeWeatherService::onLevelTickPost); NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn); NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension); + NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut); + NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping); + NeoForge.EVENT_BUS.addListener(RivencraftCommands::register); LOGGER.info("Initializing Rivencraft"); } } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java index d7e8f02..960e0c3 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -1,6 +1,8 @@ package net.dnorberg.rivencraft.book; import dev.galacticraft.dynamicdimensions.api.DynamicDimensionRegistry; +import dev.galacticraft.dynamicdimensions.api.PlayerRemover; +import dev.galacticraft.dynamicdimensions.api.event.DimensionRemovedCallback; import dev.galacticraft.dynamicdimensions.api.event.DynamicDimensionLoadCallback; import java.util.Optional; import java.util.OptionalLong; @@ -56,6 +58,7 @@ public final class AgeDimensionService { } public static void register() { + DimensionRemovedCallback.register((dimension, level) -> synchronizeImmersivePortals(level.getServer())); DynamicDimensionLoadCallback.register((server, loader) -> { ServerLevel overworld = server.overworld(); for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) { @@ -115,6 +118,27 @@ public final class AgeDimensionService { return loaded; } + /** + * Unloads an Age while retaining its saved definition and dimension data. + * A later book access reconstructs the same runtime level from that data. + */ + public static UnloadResult unload(MinecraftServer server, AgeDefinition definition) { + ResourceKey key = dimensionKey(definition); + if (server.getLevel(key) == null) { + return UnloadResult.ALREADY_UNLOADED; + } + + if (ModList.get().isLoaded("immersive_portals_core")) { + ImmersivePortalsChunkLoaders.closeForDimension(key); + } + boolean unloaded = DynamicDimensionRegistry.from(server) + .unloadDynamicDimension(definition.dimensionId(), PlayerRemover.DEFAULT); + if (!unloaded) { + return UnloadResult.FAILED; + } + return UnloadResult.UNLOADED; + } + private static void applyCelestialClock(ServerLevel level, AgeDefinition age) { if (age.traits().hasStoppedCelestialCycle()) { // NeoForge rejects an exact zero rate. The smallest positive float @@ -396,4 +420,10 @@ public final class AgeDimensionService { ); } + public enum UnloadResult { + UNLOADED, + ALREADY_UNLOADED, + FAILED + } + } diff --git a/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java b/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java index 605e724..1b71887 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java @@ -76,6 +76,7 @@ public final class LinkingService { LinkTarget target ) { if (ModList.get().isLoaded("immersive_portals_core")) { + ImmersivePortalsChunkLoaders.close(player); ImmersivePortalsChunkLoaders.teleport( player, destinationLevel, position, target.yaw(), target.pitch()); } else { diff --git a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java new file mode 100644 index 0000000..62c46f5 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java @@ -0,0 +1,81 @@ +package net.dnorberg.rivencraft.command; + +import com.mojang.brigadier.arguments.StringArgumentType; +import net.dnorberg.rivencraft.book.AgeDefinition; +import net.dnorberg.rivencraft.book.AgeDimensionService; +import net.dnorberg.rivencraft.book.AgeRegistrySavedData; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.event.RegisterCommandsEvent; + +/** Administrative commands for inspecting and safely unloading persistent Ages. */ +public final class RivencraftCommands { + private RivencraftCommands() { + } + + public static void register(RegisterCommandsEvent event) { + event.getDispatcher().register(Commands.literal("rivencraft") + .requires(source -> source.hasPermission(2)) + .then(Commands.literal("age") + .then(Commands.literal("list") + .executes(context -> listAges(context.getSource()))) + .then(Commands.literal("unload") + .then(Commands.argument("dimension", StringArgumentType.string()) + .suggests((context, builder) -> { + AgeRegistrySavedData.get(context.getSource().getLevel()).definitions() + .forEach(age -> builder.suggest(age.dimensionId().toString())); + return builder.buildFuture(); + }) + .executes(context -> unloadAge( + context.getSource(), + StringArgumentType.getString(context, "dimension") + )))))); + } + + private static int listAges(net.minecraft.commands.CommandSourceStack source) { + var ages = AgeRegistrySavedData.get(source.getLevel()).definitions().stream() + .sorted(java.util.Comparator.comparing(AgeDefinition::name)) + .toList(); + source.sendSuccess(() -> Component.literal("Rivencraft Ages: " + ages.size()), false); + for (AgeDefinition age : ages) { + boolean loaded = source.getServer().getLevel(AgeDimensionService.dimensionKey(age)) != null; + source.sendSuccess(() -> Component.literal( + (loaded ? "[loaded] " : "[unloaded] ") + + age.name() + " — " + age.dimensionId() + ), false); + } + return ages.size(); + } + + private static int unloadAge(net.minecraft.commands.CommandSourceStack source, String requestedDimension) { + ResourceLocation dimension = ResourceLocation.tryParse(requestedDimension); + if (dimension == null) { + source.sendFailure(Component.literal("Invalid dimension id: " + requestedDimension)); + return 0; + } + AgeDefinition age = AgeRegistrySavedData.get(source.getLevel()).findByDimension(dimension).orElse(null); + if (age == null) { + source.sendFailure(Component.literal("Unknown Rivencraft Age: " + dimension)); + return 0; + } + + AgeDimensionService.UnloadResult result = AgeDimensionService.unload(source.getServer(), age); + return switch (result) { + case UNLOADED -> { + source.sendSuccess(() -> Component.literal( + "Unloaded " + age.name() + ". Its definition and world data were retained." + ), true); + yield 1; + } + case ALREADY_UNLOADED -> { + source.sendFailure(Component.literal(age.name() + " is already unloaded.")); + yield 0; + } + case FAILED -> { + source.sendFailure(Component.literal("Dynamic Dimensions refused to unload " + age.name() + ".")); + yield 0; + } + }; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsChunkLoaders.java b/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsChunkLoaders.java index e595ed1..f91e709 100644 --- a/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsChunkLoaders.java +++ b/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsChunkLoaders.java @@ -1,11 +1,13 @@ package net.dnorberg.rivencraft.compat; +import java.util.IdentityHashMap; import java.util.Map; -import java.util.WeakHashMap; import net.minecraft.core.BlockPos; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerLevel; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; import net.minecraft.world.phys.Vec3; import qouteall.imm_ptl.core.api.PortalAPI; import qouteall.imm_ptl.core.chunk_loading.ChunkLoader; @@ -14,12 +16,12 @@ import qouteall.q_misc_util.dimension.DimensionIntId; /** Isolated server bridge; this class is loaded only when Immersive Portals is installed. */ public final class ImmersivePortalsChunkLoaders { - private static final Map LOADERS = new WeakHashMap<>(); + private static final Map LOADERS = new IdentityHashMap<>(); private ImmersivePortalsChunkLoaders() { } - public static void open(ServerPlayer player, ServerLevel level, BlockPos center) { + public static synchronized void open(ServerPlayer player, ServerLevel level, BlockPos center) { close(player); int centerChunkX = center.getX() >> 4; int centerChunkZ = center.getZ() >> 4; @@ -37,13 +39,28 @@ public final class ImmersivePortalsChunkLoaders { ImmPtlChunkTracking.immediatelyUpdateForPlayer(player); } - public static void close(ServerPlayer player) { + public static synchronized void close(ServerPlayer player) { ChunkLoader loader = LOADERS.remove(player); if (loader != null) { PortalAPI.removeChunkLoaderForPlayer(player, loader); } } + public static synchronized void closeForDimension(ResourceKey dimension) { + LOADERS.entrySet().removeIf(entry -> { + if (!entry.getValue().dimension().equals(dimension)) { + return false; + } + PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue()); + return true; + }); + } + + public static synchronized void closeAll() { + LOADERS.forEach(PortalAPI::removeChunkLoaderForPlayer); + LOADERS.clear(); + } + /** Registers runtime dimensions and sends the revised ID map to connected clients. */ public static void dimensionsChanged(MinecraftServer server) { DimensionIntId.onServerDimensionChanged(server); diff --git a/src/main/java/net/dnorberg/rivencraft/event/AgeLifecycleEvents.java b/src/main/java/net/dnorberg/rivencraft/event/AgeLifecycleEvents.java new file mode 100644 index 0000000..bdac187 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/event/AgeLifecycleEvents.java @@ -0,0 +1,26 @@ +package net.dnorberg.rivencraft.event; + +import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.fml.ModList; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.server.ServerStoppingEvent; + +/** Server-authoritative cleanup for resources that exist only while a preview is active. */ +public final class AgeLifecycleEvents { + private AgeLifecycleEvents() { + } + + public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity() instanceof ServerPlayer player + && ModList.get().isLoaded("immersive_portals_core")) { + ImmersivePortalsChunkLoaders.close(player); + } + } + + public static void onServerStopping(ServerStoppingEvent event) { + if (ModList.get().isLoaded("immersive_portals_core")) { + ImmersivePortalsChunkLoaders.closeAll(); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 2ebb449..43ad1a7 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -152,6 +152,42 @@ public final class LinkingGameTests { }); } + @GameTest(template = "empty", timeoutTicks = 100) + public static void unloadedAgeReloadsWithTheSameDefinition(GameTestHelper helper) { + UUID id = UUID.randomUUID(); + AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate( + helper.getLevel(), + new AgeReference(id, "Lifecycle Test Age " + id) + ); + + var server = helper.getLevel().getServer(); + var first = AgeDimensionService.ensureLoaded(server, definition); + helper.assertTrue(first != null, "The lifecycle test Age must load"); + helper.runAfterDelay(1, () -> { + helper.assertValueEqual( + AgeDimensionService.unload(server, definition), + AgeDimensionService.UnloadResult.UNLOADED, + "A loaded Age must unload without deleting its definition" + ); + helper.runAfterDelay(1, () -> { + helper.assertTrue(server.getLevel(AgeDimensionService.dimensionKey(definition)) == null, + "An unloaded Age must leave the active server level map"); + + var reloaded = AgeDimensionService.ensureLoaded(server, definition); + helper.assertTrue(reloaded != null, "A book must be able to reload an archived Age"); + helper.runAfterDelay(1, () -> { + var registered = server.getLevel(AgeDimensionService.dimensionKey(definition)); + helper.assertTrue(registered != null, "The reloaded Age must rejoin the active server level map"); + helper.assertValueEqual(registered.getSeed(), definition.seed(), + "Reloading an Age must preserve its immutable seed"); + helper.assertValueEqual(registered.dimension().location(), definition.dimensionId(), + "Reloading an Age must preserve its dimension identifier"); + helper.succeed(); + }); + }); + }); + } + @GameTest(template = "empty", timeoutTicks = 200) public static void legacyBaseTerrainProfilesLoadRuntimeDimensions(GameTestHelper helper) { for (var profile : AgeGenerationProfiles.all()) {