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

@@ -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()) {