Fix milestone 11 simulation flow

This commit is contained in:
George
2026-06-07 12:22:59 +01:00
parent 9f9b85e1f2
commit 3edc507af2
8 changed files with 463 additions and 5 deletions
+1
View File
@@ -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 {
@@ -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.");
}
}
@@ -51,5 +51,7 @@ public record RegionUpdateJob(
public static final Comparator<RegionUpdateJob> BY_PRIORITY_DESC =
Comparator.comparingInt(RegionUpdateJob::priority)
.reversed()
.thenComparingLong(RegionUpdateJob::queuedAtSimulationTick);
.thenComparingLong(RegionUpdateJob::queuedAtSimulationTick)
.thenComparing(job -> job.coordinate().stableId())
.thenComparing(job -> job.reason().name());
}
@@ -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;
}
@@ -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<String> 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<String> 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<LivingWorldEvent> 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<RegionCoordinate, Region> regions = new LinkedHashMap<>();
private int markDirtyCount;
@Override
public Optional<Region> resolve(RegionCoordinate coordinate) {
return Optional.ofNullable(regions.get(coordinate));
}
@Override
public List<Region> resolveAll(List<RegionCoordinate> 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<Region> 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); }
}
}
@@ -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<RegionUpdateJob> 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<String> 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);
}
}
@@ -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<String> 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<String> 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();
@@ -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);
}
}