From 3edc507af23e1417d3f8605a8b5edae8e8ea87ef Mon Sep 17 00:00:00 2001 From: George Date: Sun, 7 Jun 2026 12:22:59 +0100 Subject: [PATCH] Fix milestone 11 simulation flow --- build.gradle | 1 + .../java/com/livingworld/LivingWorldMod.java | 19 +- .../core/simulation/RegionUpdateJob.java | 4 +- .../core/simulation/SimulationManager.java | 2 +- .../simulation/SimulationManagerTest.java | 246 ++++++++++++++++++ .../simulation/SimulationSchedulerTest.java | 94 +++++++ .../events/LivingWorldEventBusTest.java | 34 ++- .../modules/ModuleRegistryTest.java | 68 +++++ 8 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/livingworld/core/simulation/SimulationManagerTest.java create mode 100644 src/test/java/com/livingworld/core/simulation/SimulationSchedulerTest.java create mode 100644 src/test/java/com/livingworld/modules/ModuleRegistryTest.java diff --git a/build.gradle b/build.gradle index 1205924..b7735cf 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' + testRuntimeOnly 'org.slf4j:slf4j-api:2.0.9' } test { diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index a9afd97..ff5a143 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -1,7 +1,12 @@ package com.livingworld; import net.neoforged.fml.common.Mod; +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; import net.neoforged.bus.api.IEventBus; +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 com.livingworld.bootstrap.LivingWorldBootstrap; import com.livingworld.core.LivingWorldConstants; @@ -18,12 +23,22 @@ import com.livingworld.debug.LivingWorldLogger; public class LivingWorldMod { public static final String MOD_ID = LivingWorldConstants.MOD_ID; + private final LivingWorldBootstrap bootstrap; public LivingWorldMod(IEventBus eventBus) { LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting..."); - // Delegate startup work to the bootstrap system. - new LivingWorldBootstrap().initialize(); + this.bootstrap = new LivingWorldBootstrap(); + this.bootstrap.initialize(); + + eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup()); + NeoForge.EVENT_BUS.addListener( + ServerStartingEvent.class, event -> bootstrap.onServerStarting()); + NeoForge.EVENT_BUS.addListener( + ServerStartedEvent.class, event -> bootstrap.onServerStarted()); + NeoForge.EVENT_BUS.addListener( + ServerStoppingEvent.class, event -> bootstrap.onServerStopping()); + LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully."); } } diff --git a/src/main/java/com/livingworld/core/simulation/RegionUpdateJob.java b/src/main/java/com/livingworld/core/simulation/RegionUpdateJob.java index 252d52f..3f8e3b1 100644 --- a/src/main/java/com/livingworld/core/simulation/RegionUpdateJob.java +++ b/src/main/java/com/livingworld/core/simulation/RegionUpdateJob.java @@ -51,5 +51,7 @@ public record RegionUpdateJob( public static final Comparator BY_PRIORITY_DESC = Comparator.comparingInt(RegionUpdateJob::priority) .reversed() - .thenComparingLong(RegionUpdateJob::queuedAtSimulationTick); + .thenComparingLong(RegionUpdateJob::queuedAtSimulationTick) + .thenComparing(job -> job.coordinate().stableId()) + .thenComparing(job -> job.reason().name()); } diff --git a/src/main/java/com/livingworld/core/simulation/SimulationManager.java b/src/main/java/com/livingworld/core/simulation/SimulationManager.java index 52a7dff..66cac8d 100644 --- a/src/main/java/com/livingworld/core/simulation/SimulationManager.java +++ b/src/main/java/com/livingworld/core/simulation/SimulationManager.java @@ -76,7 +76,7 @@ public final class SimulationManager { int nextJobIndex = 0; try { for (; nextJobIndex < jobs.size(); nextJobIndex++) { - if (hasExceededTimeBudget(startTimeNanos)) { + if (nextJobIndex > 0 && hasExceededTimeBudget(startTimeNanos)) { requeueJobs(jobs, nextJobIndex); break; } diff --git a/src/test/java/com/livingworld/core/simulation/SimulationManagerTest.java b/src/test/java/com/livingworld/core/simulation/SimulationManagerTest.java new file mode 100644 index 0000000..fe3ecbb --- /dev/null +++ b/src/test/java/com/livingworld/core/simulation/SimulationManagerTest.java @@ -0,0 +1,246 @@ +package com.livingworld.core.simulation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.livingworld.config.SimulationConfig; +import com.livingworld.core.services.TimeService; +import com.livingworld.data.serialization.PersistenceReader; +import com.livingworld.data.serialization.PersistenceWriter; +import com.livingworld.events.BaseLivingWorldEvent; +import com.livingworld.events.LivingWorldEvent; +import com.livingworld.events.LivingWorldEventBus; +import com.livingworld.modules.ModuleContext; +import com.livingworld.modules.ModuleMetadata; +import com.livingworld.modules.ModuleRegistry; +import com.livingworld.modules.ModuleUpdateResult; +import com.livingworld.modules.RegionUpdateContext; +import com.livingworld.modules.ServerContext; +import com.livingworld.modules.SimulationModule; +import com.livingworld.regions.Region; +import com.livingworld.regions.RegionCoordinate; +import com.livingworld.regions.RegionFactory; + +class SimulationManagerTest { + + @Test + void runsRequestedModulesPublishesEventsAndMarksChangedRegionDirty() { + Fixture fixture = new Fixture(25); + TestModule skipped = new TestModule("skipped", false, 0); + TestModule ecology = new TestModule("ecology", true, 0); + fixture.modules.register(skipped); + fixture.modules.register(ecology); + + List receivedEvents = new ArrayList<>(); + fixture.eventBus.register("region_changed", event -> receivedEvents.add(event.sourceModuleId())); + fixture.queue(Set.of("ecology")); + + fixture.manager.runSimulationCycle(); + + assertEquals(0, skipped.updateCount); + assertEquals(1, ecology.updateCount); + assertEquals(List.of("ecology"), receivedEvents); + assertTrue(fixture.region.isDirty()); + assertEquals(1, fixture.regionManager.markDirtyCount); + assertEquals(1, fixture.timeService.getSimulationTick()); + assertEquals(1, fixture.scheduler.getSimulationTickCounter()); + assertEquals(1, fixture.profiler.startCount); + assertEquals(1, fixture.profiler.endCount); + } + + @Test + void noChangeResultDoesNotMarkRegionDirty() { + Fixture fixture = new Fixture(25); + fixture.modules.register(new TestModule("ecology", false, 0)); + fixture.queue(Set.of()); + + fixture.manager.runSimulationCycle(); + + assertFalse(fixture.region.isDirty()); + assertEquals(0, fixture.regionManager.markDirtyCount); + } + + @Test + void forcedTicksRunCyclesWithoutWaitingForMinecraftInterval() { + Fixture fixture = new Fixture(25); + TestModule module = new TestModule("ecology", false, 0); + fixture.modules.register(module); + fixture.queue(Set.of()); + + fixture.manager.runForcedSimulationTicks(3); + + assertEquals(1, module.updateCount); + assertEquals(3, fixture.timeService.getSimulationTick()); + assertEquals(3, fixture.scheduler.getSimulationTickCounter()); + assertEquals(0, fixture.scheduler.getMinecraftTickCounter()); + } + + @Test + void timeBudgetRequeuesJobsNotYetProcessed() { + Fixture fixture = new Fixture(1); + fixture.modules.register(new TestModule("slow", false, 10)); + fixture.queueAt(new RegionCoordinate("minecraft:overworld", 0, 0)); + Region secondRegion = new RegionFactory().createNewRegion( + new RegionCoordinate("minecraft:overworld", 1, 0), 0); + secondRegion.clearDirty(); + fixture.regionManager.regions.put(secondRegion.getCoordinate(), secondRegion); + fixture.queueAt(secondRegion.getCoordinate()); + + fixture.manager.runSimulationCycle(); + + assertEquals(1, fixture.scheduler.getQueuedJobCount()); + } + + private static final class Fixture { + private final SimulationScheduler scheduler; + private final TestRegionManager regionManager = new TestRegionManager(); + private final ModuleRegistry modules = new ModuleRegistry(); + private final LivingWorldEventBus eventBus = new LivingWorldEventBus(); + private final TestTimeService timeService = new TestTimeService(); + private final TestProfiler profiler = new TestProfiler(); + private final Region region; + private final SimulationManager manager; + + private Fixture(int maxMillisecondsPerCycle) { + SimulationConfig config = new SimulationConfig(); + config.setMaxMillisecondsPerCycle(maxMillisecondsPerCycle); + config.setEmergencyStopMilliseconds(Math.max(40, maxMillisecondsPerCycle)); + this.scheduler = new SimulationScheduler(config); + this.region = new RegionFactory().createNewRegion( + new RegionCoordinate("minecraft:overworld", 0, 0), 0); + this.region.clearDirty(); + this.regionManager.regions.put(region.getCoordinate(), region); + this.manager = new SimulationManager( + scheduler, + regionManager, + modules, + eventBus, + timeService, + new TestPersistenceService(), + profiler); + } + + private void queue(Set requestedModules) { + scheduler.queueRegion(new RegionUpdateJob( + region.getCoordinate(), + 5, + timeService.getSimulationTick(), + requestedModules, + UpdateReason.NORMAL_ROLLING_UPDATE)); + } + + private void queueAt(RegionCoordinate coordinate) { + scheduler.queueRegion(new RegionUpdateJob( + coordinate, + 5, + timeService.getSimulationTick(), + Set.of(), + UpdateReason.NORMAL_ROLLING_UPDATE)); + } + } + + private static final class TestModule implements SimulationModule { + private final String id; + private final boolean changesRegion; + private final long sleepMilliseconds; + private int updateCount; + + private TestModule(String id, boolean changesRegion, long sleepMilliseconds) { + this.id = id; + this.changesRegion = changesRegion; + this.sleepMilliseconds = sleepMilliseconds; + } + + @Override + public String getModuleId() { + return id; + } + + @Override + public ModuleMetadata getMetadata() { + return new ModuleMetadata( + id, id, "1.0.0", "", "1", + List.of(), List.of(), true, true, false); + } + + @Override + public ModuleUpdateResult updateRegion(RegionUpdateContext context) { + updateCount++; + if (sleepMilliseconds > 0) { + try { + Thread.sleep(sleepMilliseconds); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + List events = changesRegion + ? List.of(new BaseLivingWorldEvent("region_changed", 0, id)) + : List.of(); + return new ModuleUpdateResult(changesRegion, events, List.of(), 0, List.of()); + } + + @Override public void initialize(ModuleContext context) {} + @Override public void onServerStarted(ServerContext context) {} + @Override public void createDefaultRegionData(Region region) {} + @Override public void onLivingWorldEvent(LivingWorldEvent event) {} + @Override public void saveModuleData(PersistenceWriter writer) {} + @Override public void loadModuleData(PersistenceReader reader) {} + @Override public void shutdown() {} + } + + private static final class TestRegionManager implements RegionManager { + private final Map regions = new LinkedHashMap<>(); + private int markDirtyCount; + + @Override + public Optional resolve(RegionCoordinate coordinate) { + return Optional.ofNullable(regions.get(coordinate)); + } + + @Override + public List resolveAll(List coordinates) { + return coordinates.stream().map(regions::get).filter(java.util.Objects::nonNull).toList(); + } + + @Override + public void markDirty(Region region) { + markDirtyCount++; + region.markDirty(); + } + } + + private static final class TestPersistenceService implements PersistenceService { + @Override public void save(Region region) {} + @Override public Optional load(RegionCoordinate coordinate) { return Optional.empty(); } + @Override public boolean supportsMigration() { return false; } + } + + private static final class TestProfiler implements SimulationProfiler { + private int startCount; + private int endCount; + + @Override public void startCycle(long simulationTick) { startCount++; } + @Override public void endCycle(long durationMs) { endCount++; } + } + + private static final class TestTimeService implements TimeService { + private final DefaultTimeService delegate = new DefaultTimeService(); + + @Override public long getSimulationTick() { return delegate.getSimulationTick(); } + @Override public LivingWorldCalendar getCalendar() { return delegate.getCalendar(); } + @Override public void advanceSimulationTick() { delegate.advanceSimulationTick(); } + @Override public void advanceSimulationTicks(long ticks) { delegate.advanceSimulationTicks(ticks); } + } +} diff --git a/src/test/java/com/livingworld/core/simulation/SimulationSchedulerTest.java b/src/test/java/com/livingworld/core/simulation/SimulationSchedulerTest.java new file mode 100644 index 0000000..62972c7 --- /dev/null +++ b/src/test/java/com/livingworld/core/simulation/SimulationSchedulerTest.java @@ -0,0 +1,94 @@ +package com.livingworld.core.simulation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.livingworld.config.SimulationConfig; +import com.livingworld.regions.RegionCoordinate; + +class SimulationSchedulerTest { + + @Test + void pollsHighestPriorityFirstAndRespectsRegionBudget() { + SimulationConfig config = new SimulationConfig(); + config.setMaxRegionsPerCycle(3); + SimulationScheduler scheduler = new SimulationScheduler(config); + + scheduler.queueRegion(job(2, 0)); + scheduler.queueRegion(job(10, 1)); + scheduler.queueRegion(job(5, 2)); + scheduler.queueRegion(job(8, 3)); + + List jobs = scheduler.pollJobsForCycle(); + + assertEquals(List.of(10, 8, 5), jobs.stream().map(RegionUpdateJob::priority).toList()); + assertEquals(1, scheduler.getQueuedJobCount()); + } + + @Test + void equalPriorityJobsUseQueueTickAsDeterministicTieBreaker() { + SimulationScheduler scheduler = new SimulationScheduler(new SimulationConfig()); + scheduler.queueRegion(job(5, 20)); + scheduler.queueRegion(job(5, 10)); + + assertEquals( + List.of(10L, 20L), + scheduler.pollJobsForCycle().stream() + .map(RegionUpdateJob::queuedAtSimulationTick) + .toList()); + } + + @Test + void regionUpdateJobNormalizesAndCopiesRequestedModules() { + List mutableModules = new ArrayList<>(List.of("soil")); + RegionUpdateJob job = new RegionUpdateJob( + new RegionCoordinate("minecraft:overworld", 0, 0), + 1, + 0, + Set.copyOf(mutableModules), + UpdateReason.NORMAL_ROLLING_UPDATE); + + mutableModules.add("pollution"); + + assertEquals(Set.of("soil"), job.requestedModules()); + assertThrows(UnsupportedOperationException.class, + () -> job.requestedModules().add("water")); + + RegionUpdateJob emptyModules = new RegionUpdateJob( + new RegionCoordinate("minecraft:overworld", 1, 0), + 1, + 0, + null, + UpdateReason.NORMAL_ROLLING_UPDATE); + assertEquals(Set.of(), emptyModules.requestedModules()); + } + + @Test + void simulationCounterAdvancesOnlyWhenCycleCompletes() { + SimulationConfig config = new SimulationConfig(); + config.setSimulationIntervalTicks(2); + SimulationScheduler scheduler = new SimulationScheduler(config); + + scheduler.onMinecraftTick(); + scheduler.onMinecraftTick(); + + assertEquals(0, scheduler.getSimulationTickCounter()); + scheduler.recordCompletedSimulationCycle(); + assertEquals(1, scheduler.getSimulationTickCounter()); + } + + private static RegionUpdateJob job(int priority, long queuedAtTick) { + return new RegionUpdateJob( + new RegionCoordinate("minecraft:overworld", priority, 0), + priority, + queuedAtTick, + Set.of(), + UpdateReason.NORMAL_ROLLING_UPDATE); + } +} diff --git a/src/test/java/com/livingworld/events/LivingWorldEventBusTest.java b/src/test/java/com/livingworld/events/LivingWorldEventBusTest.java index 3c6baf5..2151145 100644 --- a/src/test/java/com/livingworld/events/LivingWorldEventBusTest.java +++ b/src/test/java/com/livingworld/events/LivingWorldEventBusTest.java @@ -3,6 +3,8 @@ package com.livingworld.events; import static org.junit.jupiter.api.Assertions.*; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.ArrayList; +import java.util.List; import org.junit.jupiter.api.Test; @@ -122,6 +124,36 @@ public class LivingWorldEventBusTest { assertEquals(2, bus.getPublishedEventCount()); } + @Test + void multipleEventsAtSameTickAreAllDelivered() { + LivingWorldEventBus bus = new LivingWorldEventBus(); + List delivered = new ArrayList<>(); + bus.register("soil", event -> delivered.add(event.eventType())); + bus.register("water", event -> delivered.add(event.eventType())); + + bus.publish(new BaseLivingWorldEvent("soil", 10L, "soil")); + bus.publish(new BaseLivingWorldEvent("water", 10L, "water")); + + assertEquals(List.of("soil", "water"), delivered); + assertEquals(2, bus.getPublishedEventCount()); + } + + @Test + void recursivelyPublishedEventIsQueuedAndDelivered() { + LivingWorldEventBus bus = new LivingWorldEventBus(); + List delivered = new ArrayList<>(); + bus.register("first", event -> { + delivered.add("first"); + bus.publish(new BaseLivingWorldEvent("second", event.simulationTick(), "first")); + }); + bus.register("second", event -> delivered.add("second")); + + bus.publish(new BaseLivingWorldEvent("first", 10L, "test")); + + assertEquals(List.of("first", "second"), delivered); + assertEquals(2, bus.getPublishedEventCount()); + } + @Test void getListenerCountReturnsCorrectNumberOfListeners() { LivingWorldEventBus bus = new LivingWorldEventBus(); @@ -178,4 +210,4 @@ public class LivingWorldEventBusTest { } } } -} \ No newline at end of file +} diff --git a/src/test/java/com/livingworld/modules/ModuleRegistryTest.java b/src/test/java/com/livingworld/modules/ModuleRegistryTest.java new file mode 100644 index 0000000..6e8f57c --- /dev/null +++ b/src/test/java/com/livingworld/modules/ModuleRegistryTest.java @@ -0,0 +1,68 @@ +package com.livingworld.modules; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.livingworld.data.serialization.PersistenceReader; +import com.livingworld.data.serialization.PersistenceWriter; +import com.livingworld.events.LivingWorldEvent; +import com.livingworld.regions.Region; + +class ModuleRegistryTest { + + @Test + void preservesRegistrationOrder() { + ModuleRegistry registry = new ModuleRegistry(); + registry.register(new StubModule("soil")); + registry.register(new StubModule("water")); + registry.register(new StubModule("pollution")); + + assertEquals( + List.of("soil", "water", "pollution"), + registry.getEnabledModules().stream().map(SimulationModule::getModuleId).toList()); + } + + @Test + void rejectsMismatchedModuleAndMetadataIds() { + ModuleRegistry registry = new ModuleRegistry(); + + assertThrows(IllegalArgumentException.class, + () -> registry.register(new StubModule("soil") { + @Override + public ModuleMetadata getMetadata() { + return metadata("water"); + } + })); + } + + private static class StubModule implements SimulationModule { + private final String id; + + private StubModule(String id) { + this.id = id; + } + + @Override public String getModuleId() { return id; } + @Override public ModuleMetadata getMetadata() { return metadata(id); } + @Override public void initialize(ModuleContext context) {} + @Override public void onServerStarted(ServerContext context) {} + @Override public void createDefaultRegionData(Region region) {} + @Override public ModuleUpdateResult updateRegion(RegionUpdateContext context) { + return ModuleUpdateResult.noChange(); + } + @Override public void onLivingWorldEvent(LivingWorldEvent event) {} + @Override public void saveModuleData(PersistenceWriter writer) {} + @Override public void loadModuleData(PersistenceReader reader) {} + @Override public void shutdown() {} + } + + private static ModuleMetadata metadata(String id) { + return new ModuleMetadata( + id, id, "1.0.0", "", "1", + List.of(), List.of(), true, true, false); + } +}