diff --git a/src/main/java/com/livingworld/regions/RegionLifecycleController.java b/src/main/java/com/livingworld/regions/RegionLifecycleController.java index ef0791d..27b09a4 100644 --- a/src/main/java/com/livingworld/regions/RegionLifecycleController.java +++ b/src/main/java/com/livingworld/regions/RegionLifecycleController.java @@ -50,8 +50,7 @@ public class RegionLifecycleController { /** * Private constructor to prevent instantiation. */ - private RegionLifecycleController() { - // utility class + public RegionLifecycleController() { } // ------------------------------------------------------------------ @@ -112,4 +111,4 @@ public class RegionLifecycleController { region.setLifecycleState(target); region.markDirty(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/livingworld/regions/RegionManager.java b/src/main/java/com/livingworld/regions/RegionManager.java new file mode 100644 index 0000000..ef9a274 --- /dev/null +++ b/src/main/java/com/livingworld/regions/RegionManager.java @@ -0,0 +1,169 @@ +package com.livingworld.regions; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import com.livingworld.config.SimulationConfig; +import com.livingworld.regions.cache.RegionCache; +import com.livingworld.regions.query.RegionQueryEngine; + +/** + * Coordinates region creation, loading, caching, dirty tracking, and unloading. + */ +public final class RegionManager implements com.livingworld.core.simulation.RegionManager { + + private final RegionFactory regionFactory; + private final RegionStorage regionStorage; + private final RegionCache regionCache; + private final RegionQueryEngine queryEngine; + private final RegionLifecycleController lifecycleController; + private final SimulationConfig simulationConfig; + + public RegionManager( + RegionFactory regionFactory, + RegionStorage regionStorage, + RegionCache regionCache, + RegionQueryEngine queryEngine, + RegionLifecycleController lifecycleController, + SimulationConfig simulationConfig) { + this.regionFactory = requireNonNull(regionFactory, "regionFactory"); + this.regionStorage = requireNonNull(regionStorage, "regionStorage"); + this.regionCache = requireNonNull(regionCache, "regionCache"); + this.queryEngine = requireNonNull(queryEngine, "queryEngine"); + this.lifecycleController = requireNonNull(lifecycleController, "lifecycleController"); + this.simulationConfig = requireNonNull(simulationConfig, "simulationConfig"); + this.simulationConfig.validate(); + } + + public Region getOrCreateRegion(RegionCoordinate coordinate) { + requireNonNull(coordinate, "coordinate"); + + Optional cached = regionCache.get(coordinate); + if (cached.isPresent()) { + return cached.get(); + } + + Region region = regionStorage.load(coordinate) + .map(this::prepareLoadedRegion) + .orElseGet(() -> regionFactory.createNewRegion(coordinate, 0L)); + + if (!region.getCoordinate().equals(coordinate)) { + throw new IllegalStateException( + "Loaded region coordinate " + region.getCoordinate() + + " does not match requested coordinate " + coordinate); + } + + regionCache.put(region); + return region; + } + + public Optional findRegion(RegionCoordinate coordinate) { + requireNonNull(coordinate, "coordinate"); + return queryEngine.getRegion(coordinate); + } + + public Region getOrCreateRegionAtBlock( + String dimensionId, + int blockX, + int blockZ) { + RegionCoordinate coordinate = RegionCoordinate.fromBlock( + dimensionId, + blockX, + blockZ, + simulationConfig.getRegionSizeChunks()); + return getOrCreateRegion(coordinate); + } + + public Collection getActiveRegions() { + return regionCache.allActive(); + } + + @Override + public void markDirty(Region region) { + requireNonNull(region, "region"); + Region cached = regionCache.get(region.getCoordinate()) + .orElseThrow(() -> new IllegalArgumentException( + "region is not managed by this RegionManager: " + region.getCoordinate())); + if (cached != region) { + throw new IllegalArgumentException( + "a different region instance is already cached for " + region.getCoordinate()); + } + region.markDirty(); + } + + public void unloadRegion(RegionCoordinate coordinate) { + requireNonNull(coordinate, "coordinate"); + Optional existing = regionCache.get(coordinate); + if (existing.isEmpty()) { + return; + } + + Region region = existing.get(); + if (region.isDirty()) { + regionStorage.save(region); + region.clearDirty(); + } + + transitionForUnload(region); + regionCache.remove(coordinate); + } + + @Override + public Optional resolve(RegionCoordinate coordinate) { + return findRegion(coordinate); + } + + @Override + public List resolveAll(List coordinates) { + requireNonNull(coordinates, "coordinates"); + List resolved = new ArrayList<>(); + for (RegionCoordinate coordinate : coordinates) { + findRegion(requireNonNull(coordinate, "coordinate")) + .ifPresent(resolved::add); + } + return List.copyOf(resolved); + } + + private Region prepareLoadedRegion(Region region) { + requireNonNull(region, "loaded region"); + region.validate(); + + switch (region.getLifecycleState()) { + case UNLOADED, FAILED -> { + lifecycleController.transition(region, RegionLifecycleState.LOADING); + lifecycleController.transition(region, RegionLifecycleState.ACTIVE); + } + case LOADING -> lifecycleController.transition(region, RegionLifecycleState.ACTIVE); + case ACTIVE, DIRTY -> { + // Already available for simulation. + } + case SAVING, UNLOADING -> throw new IllegalStateException( + "Cannot activate a region in state " + region.getLifecycleState()); + } + return region; + } + + private void transitionForUnload(Region region) { + switch (region.getLifecycleState()) { + case ACTIVE -> { + lifecycleController.transition(region, RegionLifecycleState.UNLOADING); + lifecycleController.transition(region, RegionLifecycleState.UNLOADED); + region.clearDirty(); + } + case UNLOADED -> { + // Nothing to do. + } + default -> throw new IllegalStateException( + "Cannot unload a region in state " + region.getLifecycleState()); + } + } + + private static T requireNonNull(T value, String name) { + if (value == null) { + throw new IllegalArgumentException(name + " must not be null"); + } + return value; + } +} diff --git a/src/test/java/com/livingworld/regions/RegionManagerTest.java b/src/test/java/com/livingworld/regions/RegionManagerTest.java new file mode 100644 index 0000000..cce84fe --- /dev/null +++ b/src/test/java/com/livingworld/regions/RegionManagerTest.java @@ -0,0 +1,126 @@ +package com.livingworld.regions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import com.livingworld.config.SimulationConfig; +import com.livingworld.core.services.PersistenceService; +import com.livingworld.regions.cache.RegionCache; +import com.livingworld.regions.query.RegionQueryEngine; + +class RegionManagerTest { + + @Test + void checksCacheBeforeStorageAndPreventsDuplicateInstances() { + Fixture fixture = new Fixture(); + RegionCoordinate coordinate = new RegionCoordinate("minecraft:overworld", 0, 0); + + Region first = fixture.manager.getOrCreateRegion(coordinate); + Region second = fixture.manager.getOrCreateRegion(coordinate); + + assertSame(first, second); + assertEquals(1, fixture.persistence.loadCount); + } + + @Test + void loadsFromStorageBeforeCreatingRegion() { + Fixture fixture = new Fixture(); + RegionCoordinate coordinate = new RegionCoordinate("minecraft:overworld", 2, 3); + Region stored = fixture.factory.createNewRegion(coordinate, 12); + stored.clearDirty(); + fixture.persistence.stored.put(coordinate, stored); + + Region loaded = fixture.manager.getOrCreateRegion(coordinate); + + assertSame(stored, loaded); + assertEquals(1, fixture.cache.size()); + } + + @Test + void createsMissingRegionAndConvertsBlockCoordinates() { + Fixture fixture = new Fixture(); + + Region created = fixture.manager.getOrCreateRegionAtBlock( + "minecraft:overworld", -129, 128); + + assertEquals(new RegionCoordinate("minecraft:overworld", -2, 1), created.getCoordinate()); + assertTrue(created.isDirty()); + } + + @Test + void unloadSavesDirtyRegionAndRemovesItFromCache() { + Fixture fixture = new Fixture(); + Region region = fixture.manager.getOrCreateRegion( + new RegionCoordinate("minecraft:overworld", 0, 0)); + + fixture.manager.unloadRegion(region.getCoordinate()); + + assertEquals(1, fixture.persistence.saveCount); + assertTrue(fixture.manager.findRegion(region.getCoordinate()).isEmpty()); + assertEquals(RegionLifecycleState.UNLOADED, region.getLifecycleState()); + } + + @Test + void rejectsDifferentInstanceForCachedCoordinate() { + Fixture fixture = new Fixture(); + RegionCoordinate coordinate = new RegionCoordinate("minecraft:overworld", 0, 0); + Region managed = fixture.manager.getOrCreateRegion(coordinate); + Region duplicate = fixture.factory.createNewRegion(coordinate, 0); + + assertNotSame(managed, duplicate); + assertThrows(IllegalArgumentException.class, () -> fixture.manager.markDirty(duplicate)); + } + + private static final class Fixture { + private final RegionFactory factory = new RegionFactory(); + private final TestPersistenceService persistence = new TestPersistenceService(); + private final RegionCache cache = new RegionCache(); + private final RegionManager manager = new RegionManager( + factory, + new RegionStorage(persistence), + cache, + new RegionQueryEngine(cache), + new RegionLifecycleController(), + new SimulationConfig()); + } + + private static final class TestPersistenceService implements PersistenceService { + private final Map stored = new LinkedHashMap<>(); + private int loadCount; + private int saveCount; + + @Override + public void markRegionDirty(Region region) { + region.markDirty(); + } + + @Override + public void saveDirtyRegions() { + } + + @Override + public Optional loadRegion(RegionCoordinate coordinate) { + loadCount++; + return Optional.ofNullable(stored.get(coordinate)); + } + + @Override + public void saveRegion(Region region) { + saveCount++; + stored.put(region.getCoordinate(), region); + } + + @Override + public void flushAll() { + } + } +}