Complete Age lifecycle management (#8)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-23 00:55:26 -04:00
1 parent c0bc91858e
commit b06fbda461
10 files changed
+242 -5

No files matched your search

+39
View File
@@ -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 <dimension>` 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.
+2
View File
@@ -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.
+1 -1
View File
@@ -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.
@@ -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");
}
}
@@ -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<Level> 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
}
}
@@ -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 {
@@ -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;
}
};
}
}
@@ -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<ServerPlayer, ChunkLoader> LOADERS = new WeakHashMap<>();
private static final Map<ServerPlayer, ChunkLoader> 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<Level> 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);
@@ -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();
}
}
}
@@ -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()) {