From e23f362b12fceb8d9f615adb8a1ae094a8ddaa3d Mon Sep 17 00:00:00 2001 From: George Date: Sun, 7 Jun 2026 13:36:54 +0100 Subject: [PATCH] Add sustained simulation validation --- .../testing/LongRunSimulationTest.java | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/test/java/com/livingworld/testing/LongRunSimulationTest.java diff --git a/src/test/java/com/livingworld/testing/LongRunSimulationTest.java b/src/test/java/com/livingworld/testing/LongRunSimulationTest.java new file mode 100644 index 0000000..f4626ab --- /dev/null +++ b/src/test/java/com/livingworld/testing/LongRunSimulationTest.java @@ -0,0 +1,213 @@ +package com.livingworld.testing; + +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 com.livingworld.config.SimulationConfig; +import com.livingworld.core.services.ServiceRegistry; +import com.livingworld.core.simulation.DefaultTimeService; +import com.livingworld.core.simulation.PersistenceService; +import com.livingworld.core.simulation.RegionManager; +import com.livingworld.core.simulation.RegionUpdateJob; +import com.livingworld.core.simulation.SimulationManager; +import com.livingworld.core.simulation.SimulationScheduler; +import com.livingworld.core.simulation.UpdateReason; +import com.livingworld.debug.SimulationProfileSnapshot; +import com.livingworld.debug.SimulationProfiler; +import com.livingworld.events.LivingWorldEventBus; +import com.livingworld.modules.ModuleContext; +import com.livingworld.modules.ModuleRegistry; +import com.livingworld.regions.Region; +import com.livingworld.regions.RegionCoordinate; +import com.livingworld.regions.RegionFactory; +import com.livingworld.regions.RegionLifecycleState; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +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; + +class LongRunSimulationTest { + + private static final int REGION_COUNT = 1_000; + private static final int SIMULATION_CYCLES = 10_000; + private static final int JOBS_PER_CYCLE = 50; + private static final int SAVE_INTERVAL_CYCLES = 100; + + @Test + void simulationRemainsStableUnderSustainedLoad() { + org.junit.jupiter.api.Assertions.assertTimeout( + Duration.ofSeconds(30), + this::runSustainedSimulation); + } + + private void runSustainedSimulation() { + SimulationConfig config = new SimulationConfig(); + config.setMaxRegionsPerCycle(JOBS_PER_CYCLE); + config.setMaxMillisecondsPerCycle(1_000); + config.setEmergencyStopMilliseconds(1_000); + + SimulationScheduler scheduler = new SimulationScheduler(config); + InMemoryRegionManager regionManager = new InMemoryRegionManager(); + createRegions(regionManager); + + TestSimulationModule testModule = new TestSimulationModule(); + ModuleRegistry moduleRegistry = new ModuleRegistry(); + moduleRegistry.register(testModule); + moduleRegistry.initializeAll(new ModuleContext(new ServiceRegistry())); + + DefaultTimeService timeService = new DefaultTimeService(); + SimulationProfiler profiler = new SimulationProfiler(); + InMemoryPersistenceService persistence = + new InMemoryPersistenceService(regionManager.allRegions()); + SimulationManager simulationManager = new SimulationManager( + scheduler, + regionManager, + moduleRegistry, + new LivingWorldEventBus(), + timeService, + persistence, + profiler); + + for (int cycle = 0; cycle < SIMULATION_CYCLES; cycle++) { + queueCycleJobs(scheduler, regionManager, cycle); + simulationManager.runSimulationCycle(); + + if ((cycle + 1) % SAVE_INTERVAL_CYCLES == 0) { + persistence.saveDirtyRegions(); + } + } + persistence.saveDirtyRegions(); + + SimulationProfileSnapshot snapshot = profiler.createSnapshot(); + assertTrue(testModule.isInitialized()); + assertEquals(SIMULATION_CYCLES * JOBS_PER_CYCLE, testModule.getUpdateCount()); + assertEquals(SIMULATION_CYCLES, timeService.getSimulationTick()); + assertEquals(SIMULATION_CYCLES, scheduler.getSimulationTickCounter()); + assertEquals(0, scheduler.getQueuedJobCount()); + assertTrue(persistence.getSaveCount() > 0); + assertTrue(persistence.getMaxDirtyObserved() <= REGION_COUNT); + assertEquals(0, persistence.countDirtyRegions()); + assertTrue(snapshot.totalCycleNanos() > 0); + assertTrue(snapshot.moduleTimings().containsKey(TestSimulationModule.MODULE_ID)); + assertEquals(JOBS_PER_CYCLE, snapshot.regionsUpdated()); + assertFalse(snapshot.budgetExceeded()); + assertTrue(regionManager.allRegions().stream() + .allMatch(region -> region.getLifecycleState() == RegionLifecycleState.ACTIVE)); + } + + private static void createRegions(InMemoryRegionManager regionManager) { + RegionFactory factory = new RegionFactory(); + for (int index = 0; index < REGION_COUNT; index++) { + Region region = factory.createNewRegion( + new RegionCoordinate("minecraft:overworld", index, 0), + 0); + region.clearDirty(); + regionManager.add(region); + } + } + + private static void queueCycleJobs( + SimulationScheduler scheduler, + InMemoryRegionManager regionManager, + int cycle) { + int firstRegion = cycle * JOBS_PER_CYCLE; + for (int offset = 0; offset < JOBS_PER_CYCLE; offset++) { + Region region = regionManager.get((firstRegion + offset) % REGION_COUNT); + scheduler.queueRegion(new RegionUpdateJob( + region.getCoordinate(), + 1, + cycle, + Set.of(TestSimulationModule.MODULE_ID), + UpdateReason.NORMAL_ROLLING_UPDATE)); + } + } + + private static final class InMemoryRegionManager implements RegionManager { + private final Map regions = new LinkedHashMap<>(); + private final List orderedRegions = new ArrayList<>(); + + private void add(Region region) { + regions.put(region.getCoordinate(), region); + orderedRegions.add(region); + } + + private Region get(int index) { + return orderedRegions.get(index); + } + + private Collection allRegions() { + return orderedRegions; + } + + @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) { + region.markDirty(); + } + } + + private static final class InMemoryPersistenceService implements PersistenceService { + private final Collection regions; + private final Map savedRegions = new LinkedHashMap<>(); + private int saveCount; + private int maxDirtyObserved; + + private InMemoryPersistenceService(Collection regions) { + this.regions = regions; + } + + private void saveDirtyRegions() { + maxDirtyObserved = Math.max(maxDirtyObserved, countDirtyRegions()); + regions.stream() + .filter(Region::isDirty) + .forEach(this::save); + } + + private int countDirtyRegions() { + return (int) regions.stream().filter(Region::isDirty).count(); + } + + private int getSaveCount() { + return saveCount; + } + + private int getMaxDirtyObserved() { + return maxDirtyObserved; + } + + @Override + public void save(Region region) { + savedRegions.put(region.getCoordinate(), region); + region.clearDirty(); + saveCount++; + } + + @Override + public Optional load(RegionCoordinate coordinate) { + return Optional.ofNullable(savedRegions.get(coordinate)); + } + + @Override + public boolean supportsMigration() { + return false; + } + } +}