From ae2a8db3ce0af62a7871b1ae0619170020c34925 Mon Sep 17 00:00:00 2001 From: George Date: Sun, 7 Jun 2026 14:11:10 +0100 Subject: [PATCH] Wire Volume 1 server lifecycle --- .../java/com/livingworld/LivingWorldMod.java | 12 +- .../bootstrap/LivingWorldBootstrap.java | 198 +++++++++++++++++- .../commands/LivingWorldCommandRoot.java | 37 +++- .../core/LivingWorldConstants.java | 5 +- .../bootstrap/LivingWorldBootstrapTest.java | 93 ++++++++ 5 files changed, 327 insertions(+), 18 deletions(-) create mode 100644 src/test/java/com/livingworld/bootstrap/LivingWorldBootstrapTest.java diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index ff5a143..0d08e19 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -7,11 +7,13 @@ import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.server.ServerStartedEvent; import net.neoforged.neoforge.event.server.ServerStartingEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent; +import net.minecraft.world.level.storage.LevelResource; import com.livingworld.bootstrap.LivingWorldBootstrap; import com.livingworld.core.LivingWorldConstants; import com.livingworld.debug.DiagnosticCategory; import com.livingworld.debug.LivingWorldLogger; +import com.livingworld.platform.neoforge.NeoForgePlatformAdapter; /** * Mod entrypoint for Living World. @@ -29,11 +31,17 @@ public class LivingWorldMod { LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting..."); this.bootstrap = new LivingWorldBootstrap(); - this.bootstrap.initialize(); + NeoForgePlatformAdapter platformAdapter = new NeoForgePlatformAdapter( + bootstrap::getWorldSaveDirectory, + bootstrap::registerCommands, + bootstrap::onServerTick); + this.bootstrap.initialize(platformAdapter); eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup()); NeoForge.EVENT_BUS.addListener( - ServerStartingEvent.class, event -> bootstrap.onServerStarting()); + ServerStartingEvent.class, + event -> bootstrap.onServerStarting( + event.getServer().getWorldPath(LevelResource.ROOT))); NeoForge.EVENT_BUS.addListener( ServerStartedEvent.class, event -> bootstrap.onServerStarted()); NeoForge.EVENT_BUS.addListener( diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index 2bc0eb1..a5818fc 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -1,48 +1,224 @@ package com.livingworld.bootstrap; +import com.livingworld.commands.LivingWorldCommandRoot; +import com.livingworld.config.DefaultConfigService; +import com.livingworld.config.SimulationConfig; +import com.livingworld.core.LivingWorldConstants; +import com.livingworld.core.services.CoreServices; +import com.livingworld.core.services.FileRegionPersistenceService; +import com.livingworld.core.services.ServiceRegistry; +import com.livingworld.core.simulation.DefaultTimeService; +import com.livingworld.core.simulation.SimulationManager; +import com.livingworld.core.simulation.SimulationScheduler; +import com.livingworld.debug.SimulationProfiler; import com.livingworld.debug.DiagnosticCategory; import com.livingworld.debug.LivingWorldLogger; +import com.livingworld.events.LivingWorldEventBus; +import com.livingworld.modules.ModuleContext; +import com.livingworld.modules.ModuleRegistry; +import com.livingworld.modules.ServerContext; +import com.livingworld.platform.PlatformAdapter; +import com.livingworld.regions.RegionFactory; +import com.livingworld.regions.RegionLifecycleController; +import com.livingworld.regions.RegionManager; +import com.livingworld.regions.RegionStorage; +import com.livingworld.regions.cache.RegionCache; +import com.livingworld.regions.query.RegionQueryEngine; +import com.mojang.brigadier.CommandDispatcher; +import java.nio.file.Path; +import net.minecraft.commands.CommandSourceStack; /** * Bootstrap class for Living World mod startup lifecycle. - * - * Each method logs its lifecycle stage. No gameplay logic, region/ecosystem, - * or persistence systems are implemented yet. */ -public class LivingWorldBootstrap { +public final class LivingWorldBootstrap { + + private PlatformAdapter platformAdapter; + private Path worldSaveDirectory; + private ServiceRegistry services; + private RegionManager regionManager; + private ModuleRegistry moduleRegistry; + private SimulationManager simulationManager; + private boolean initialized; + private boolean serverReady; /** * Called once during mod construction. */ - public void initialize() { - LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "initialize — mod bootstrap initialized."); + public void initialize(PlatformAdapter platformAdapter) { + if (initialized) { + throw new IllegalStateException("bootstrap is already initialized"); + } + if (platformAdapter == null) { + throw new IllegalArgumentException("platformAdapter must not be null"); + } + this.platformAdapter = platformAdapter; + this.platformAdapter.registerCommands(); + this.platformAdapter.registerServerTickHook(); + this.platformAdapter.registerPlayerEventHooks(); + this.initialized = true; + + LivingWorldLogger.info( + DiagnosticCategory.BOOTSTRAP, + "Platform=" + platformAdapter.getPlatformName() + + ", minecraft=" + platformAdapter.getMinecraftVersion() + + ", loader=" + platformAdapter.getLoaderVersion() + + ", dedicatedServer=" + platformAdapter.isDedicatedServer()); + LivingWorldLogger.info( + DiagnosticCategory.BOOTSTRAP, + "initialize - mod bootstrap initialized."); } /** * Called during common (dedicated & integrated server) setup phase. */ public void onCommonSetup() { - LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "onCommonSetup — common setup phase reached."); + requireInitialized(); + LivingWorldLogger.info( + DiagnosticCategory.BOOTSTRAP, + "onCommonSetup - common setup phase reached."); } /** * Called when the server is starting. */ - public void onServerStarting() { - LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "onServerStarting — server starting."); + public void onServerStarting(Path worldSaveDirectory) { + requireInitialized(); + if (worldSaveDirectory == null) { + throw new IllegalArgumentException("worldSaveDirectory must not be null"); + } + if (serverReady) { + throw new IllegalStateException("server services are already initialized"); + } + this.worldSaveDirectory = worldSaveDirectory; + createServerServices(worldSaveDirectory); + this.serverReady = true; + LivingWorldLogger.info( + DiagnosticCategory.BOOTSTRAP, + "onServerStarting - services ready at " + worldSaveDirectory); } /** * Called when the server has finished starting. */ public void onServerStarted() { - LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "onServerStarted — server started."); + requireServerReady(); + ServerContext context = new ServerContext(); + moduleRegistry.getEnabledModules().forEach(module -> module.onServerStarted(context)); + LivingWorldLogger.info( + DiagnosticCategory.BOOTSTRAP, + "onServerStarted - server started."); } /** * Called when the server is stopping. */ public void onServerStopping() { - LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "onServerStopping — server stopping."); + if (!serverReady) { + return; + } + regionManager.flushAll(); + moduleRegistry.shutdownAll(); + serverReady = false; + LivingWorldLogger.info( + DiagnosticCategory.BOOTSTRAP, + "onServerStopping - persistence flushed and modules stopped."); + } + + public void onServerTick() { + if (!serverReady) { + return; + } + long previousSimulationTick = simulationManager.getSimulationTickCounter(); + simulationManager.onMinecraftServerTick(); + if (simulationManager.getSimulationTickCounter() != previousSimulationTick) { + regionManager.saveDirtyRegions(); + } + } + + public void registerCommands(CommandDispatcher dispatcher) { + requireInitialized(); + LivingWorldCommandRoot.registerDeferred( + dispatcher, + () -> requireService(regionManager, "regionManager"), + () -> requireService(moduleRegistry, "moduleRegistry"), + () -> requireService(simulationManager, "simulationManager")); + } + + public Path getWorldSaveDirectory() { + return worldSaveDirectory; + } + + public boolean isServerReady() { + return serverReady; + } + + public ServiceRegistry getServices() { + requireServerReady(); + return services; + } + + private void createServerServices(Path saveDirectory) { + DefaultConfigService configService = new DefaultConfigService(); + SimulationConfig config = configService.getSimulationConfig(); + FileRegionPersistenceService persistenceService = + new FileRegionPersistenceService( + saveDirectory.resolve("living_world"), + LivingWorldConstants.MOD_VERSION); + RegionCache regionCache = new RegionCache(); + RegionQueryEngine queryEngine = new RegionQueryEngine(regionCache); + regionManager = new RegionManager( + new RegionFactory(), + new RegionStorage(persistenceService), + regionCache, + queryEngine, + new RegionLifecycleController(), + config); + moduleRegistry = new ModuleRegistry(); + LivingWorldEventBus eventBus = new LivingWorldEventBus(); + DefaultTimeService timeService = new DefaultTimeService(); + SimulationScheduler scheduler = new SimulationScheduler(config); + SimulationProfiler profiler = new SimulationProfiler(); + simulationManager = new SimulationManager( + scheduler, + regionManager, + moduleRegistry, + eventBus, + timeService, + persistenceService, + profiler); + + services = new ServiceRegistry(); + services.register(CoreServices.CONFIG, configService); + services.register(CoreServices.REGIONS, regionManager); + services.register(CoreServices.SIMULATION, simulationManager); + services.register(CoreServices.PERSISTENCE, persistenceService); + services.register(CoreServices.EVENTS, eventBus); + services.register(CoreServices.MODULES, moduleRegistry); + services.register(CoreServices.TIME, timeService); + services.register(CoreServices.DEBUG, profiler); + services.lock(); + + moduleRegistry.initializeAll(new ModuleContext(services)); + } + + private void requireInitialized() { + if (!initialized) { + throw new IllegalStateException("bootstrap has not been initialized"); + } + } + + private void requireServerReady() { + requireInitialized(); + if (!serverReady) { + throw new IllegalStateException("server services are not ready"); + } + } + + private static T requireService(T service, String name) { + if (service == null) { + throw new IllegalStateException(name + " is not ready"); + } + return service; } } diff --git a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java index fba16e2..83c3590 100644 --- a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java +++ b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java @@ -1,6 +1,7 @@ package com.livingworld.commands; import java.util.stream.Collectors; +import java.util.function.Supplier; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; @@ -28,6 +29,18 @@ public final class LivingWorldCommandRoot { RegionManager regionManager, ModuleRegistry moduleRegistry, SimulationManager simulationManager) { + registerDeferred( + dispatcher, + () -> regionManager, + () -> moduleRegistry, + () -> simulationManager); + } + + public static void registerDeferred( + CommandDispatcher dispatcher, + Supplier regionManager, + Supplier moduleRegistry, + Supplier simulationManager) { if (dispatcher == null) { throw new IllegalArgumentException("dispatcher must not be null"); } @@ -45,15 +58,20 @@ public final class LivingWorldCommandRoot { .requires(source -> source.hasPermission(OPERATOR_PERMISSION_LEVEL)) .then(Commands.literal("status") .executes(context -> showStatus( - context.getSource(), regionManager, moduleRegistry, simulationManager))) + context.getSource(), + requireService(regionManager, "regionManager"), + requireService(moduleRegistry, "moduleRegistry"), + requireService(simulationManager, "simulationManager")))) .then(Commands.literal("region") .then(Commands.literal("info") .executes(context -> RegionInfoCommand.execute( - context.getSource(), regionManager)))) + context.getSource(), + requireService(regionManager, "regionManager"))))) .then(Commands.literal("modules") .then(Commands.literal("list") .executes(context -> listModules( - context.getSource(), moduleRegistry)))) + context.getSource(), + requireService(moduleRegistry, "moduleRegistry"))))) .then(Commands.literal("simulate") .then(Commands.argument( "ticks", @@ -61,10 +79,21 @@ public final class LivingWorldCommandRoot { 1, SimulationManager.MAX_FORCED_SIMULATION_TICKS)) .executes(context -> SimulateCommand.execute( context.getSource(), - simulationManager, + requireService(simulationManager, "simulationManager"), IntegerArgumentType.getInteger(context, "ticks")))))); } + private static T requireService(Supplier supplier, String name) { + if (supplier == null) { + throw new IllegalArgumentException(name + " supplier must not be null"); + } + T service = supplier.get(); + if (service == null) { + throw new IllegalStateException(name + " is not ready"); + } + return service; + } + private static int showStatus( CommandSourceStack source, RegionManager regionManager, diff --git a/src/main/java/com/livingworld/core/LivingWorldConstants.java b/src/main/java/com/livingworld/core/LivingWorldConstants.java index 158af9b..f45555d 100644 --- a/src/main/java/com/livingworld/core/LivingWorldConstants.java +++ b/src/main/java/com/livingworld/core/LivingWorldConstants.java @@ -15,6 +15,9 @@ public final class LivingWorldConstants { /** Human-readable mod name displayed in the mod list. */ public static final String MOD_NAME = "Living World"; + /** Current mod version. Keep aligned with the Gradle project version. */ + public static final String MOD_VERSION = "0.1.0"; + /** Current schema version for data persistence and migration tracking. */ public static final int CURRENT_CORE_SCHEMA_VERSION = 1; @@ -23,4 +26,4 @@ public final class LivingWorldConstants { /** Default simulation update interval in ticks (100 ticks ≈ 5 seconds). */ public static final int DEFAULT_SIMULATION_INTERVAL_TICKS = 100; -} \ No newline at end of file +} diff --git a/src/test/java/com/livingworld/bootstrap/LivingWorldBootstrapTest.java b/src/test/java/com/livingworld/bootstrap/LivingWorldBootstrapTest.java new file mode 100644 index 0000000..5ecb7b7 --- /dev/null +++ b/src/test/java/com/livingworld/bootstrap/LivingWorldBootstrapTest.java @@ -0,0 +1,93 @@ +package com.livingworld.bootstrap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.livingworld.core.services.CoreServices; +import com.livingworld.core.services.TimeService; +import com.livingworld.platform.PlatformAdapter; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LivingWorldBootstrapTest { + + @TempDir + Path temporaryDirectory; + + @Test + void createsAndLocksCompleteServerServiceGraph() { + LivingWorldBootstrap bootstrap = new LivingWorldBootstrap(); + TestPlatformAdapter platform = new TestPlatformAdapter(temporaryDirectory); + + bootstrap.initialize(platform); + bootstrap.onCommonSetup(); + bootstrap.onServerStarting(temporaryDirectory); + bootstrap.onServerStarted(); + + assertTrue(bootstrap.isServerReady()); + assertEquals(1, platform.commandRegistrations); + assertEquals(1, platform.tickRegistrations); + assertTrue(bootstrap.getServices().isLocked()); + assertTrue(bootstrap.getServices().isRegistered(CoreServices.CONFIG)); + assertTrue(bootstrap.getServices().isRegistered(CoreServices.REGIONS)); + assertTrue(bootstrap.getServices().isRegistered(CoreServices.SIMULATION)); + assertTrue(bootstrap.getServices().isRegistered(CoreServices.PERSISTENCE)); + assertTrue(bootstrap.getServices().isRegistered(CoreServices.EVENTS)); + assertTrue(bootstrap.getServices().isRegistered(CoreServices.MODULES)); + assertTrue(bootstrap.getServices().isRegistered(CoreServices.TIME)); + assertTrue(bootstrap.getServices().isRegistered(CoreServices.DEBUG)); + + for (int tick = 0; tick < 100; tick++) { + bootstrap.onServerTick(); + } + TimeService timeService = bootstrap.getServices().get(CoreServices.TIME); + assertEquals(1, timeService.getSimulationTick()); + + bootstrap.onServerStopping(); + + assertFalse(bootstrap.isServerReady()); + assertTrue(Files.exists(temporaryDirectory.resolve("living_world/metadata.properties"))); + } + + @Test + void ignoresTicksUntilServerServicesAreReady() { + LivingWorldBootstrap bootstrap = new LivingWorldBootstrap(); + bootstrap.initialize(new TestPlatformAdapter(temporaryDirectory)); + + bootstrap.onServerTick(); + + assertFalse(bootstrap.isServerReady()); + } + + @Test + void rejectsServerLifecycleBeforeInitialization() { + LivingWorldBootstrap bootstrap = new LivingWorldBootstrap(); + + assertThrows( + IllegalStateException.class, + () -> bootstrap.onServerStarting(temporaryDirectory)); + } + + private static final class TestPlatformAdapter implements PlatformAdapter { + private final Path saveDirectory; + private int commandRegistrations; + private int tickRegistrations; + + private TestPlatformAdapter(Path saveDirectory) { + this.saveDirectory = saveDirectory; + } + + @Override public String getPlatformName() { return "test"; } + @Override public String getMinecraftVersion() { return "test"; } + @Override public String getLoaderVersion() { return "test"; } + @Override public boolean isDedicatedServer() { return true; } + @Override public Path getWorldSaveDirectory() { return saveDirectory; } + @Override public void registerCommands() { commandRegistrations++; } + @Override public void registerServerTickHook() { tickRegistrations++; } + @Override public void registerPlayerEventHooks() {} + } +}