Public Access
This commit is contained in:
1 parent
c0bc91858e
commit
b06fbda461
10 files changed
+242
-5
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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()) {
|
||||
|
||||
Reference in new issue
Block a user