Fix milestone 11 simulation flow
This commit is contained in:
@@ -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();
|
||||
@@ -178,4 +210,4 @@ public class LivingWorldEventBusTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user