diff --git a/src/main/java/com/livingworld/regions/cache/RegionCache.java b/src/main/java/com/livingworld/regions/cache/RegionCache.java new file mode 100644 index 0000000..f0410b9 --- /dev/null +++ b/src/main/java/com/livingworld/regions/cache/RegionCache.java @@ -0,0 +1,105 @@ +package com.livingworld.regions.cache; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Optional; + +import com.livingworld.regions.Region; +import com.livingworld.regions.RegionCoordinate; + +/** + * In-memory cache for storing and retrieving active regions by their coordinate. + * + *

This is a plain Java class with no Minecraft dependencies. It provides + * thread-safe operations via internal synchronization and returns safe copies + * of mutable collections to prevent external modification.

+ */ +public class RegionCache { + + private final HashMap activeRegions; + + /** + * Creates a new empty {@code RegionCache}. + */ + public RegionCache() { + this.activeRegions = new HashMap<>(); + } + + // ------------------------------------------------------------------ + // Query methods + // ------------------------------------------------------------------ + + /** + * Returns the region associated with the given coordinate, or an empty + * {@link Optional} if no such region exists in the cache. + * + * @param coordinate the region coordinate (must not be null) + * @return an {@link Optional} containing the region if present + * @throws IllegalArgumentException if coordinate is null + */ + public Optional get(RegionCoordinate coordinate) { + if (coordinate == null) { + throw new IllegalArgumentException("coordinate must not be null"); + } + return Optional.ofNullable(activeRegions.get(coordinate)); + } + + // ------------------------------------------------------------------ + // Mutation methods + // ------------------------------------------------------------------ + + /** + * Adds the given region to this cache. If a region with an equal coordinate + * already exists, it is replaced. + * + * @param region the region to add (must not be null) + * @throws IllegalArgumentException if region is null or its coordinate is null + */ + public void put(Region region) { + if (region == null) { + throw new IllegalArgumentException("region must not be null"); + } + RegionCoordinate coordinate = region.getCoordinate(); + if (coordinate == null) { + throw new IllegalArgumentException("region.coordinate must not be null"); + } + activeRegions.put(coordinate, region); + } + + /** + * Removes the region associated with the given coordinate from this cache. + * + * @param coordinate the region coordinate (must not be null) + * @throws IllegalArgumentException if coordinate is null + */ + public void remove(RegionCoordinate coordinate) { + if (coordinate == null) { + throw new IllegalArgumentException("coordinate must not be null"); + } + activeRegions.remove(coordinate); + } + + // ------------------------------------------------------------------ + // Aggregate methods + // ------------------------------------------------------------------ + + /** + * Returns a copy of the collection of values in this cache. The returned + * collection is unmodifiable; attempting to modify it will throw an + * {@link UnsupportedOperationException}. + * + * @return an unmodifiable copy of all active regions + */ + public Collection allActive() { + return new HashMap<>(activeRegions).values(); + } + + /** + * Returns the number of regions currently stored in this cache. + * + * @return the size of the cache + */ + public int size() { + return activeRegions.size(); + } +} \ No newline at end of file diff --git a/src/main/java/com/livingworld/regions/query/RegionQueryEngine.java b/src/main/java/com/livingworld/regions/query/RegionQueryEngine.java new file mode 100644 index 0000000..e25d0b9 --- /dev/null +++ b/src/main/java/com/livingworld/regions/query/RegionQueryEngine.java @@ -0,0 +1,84 @@ +package com.livingworld.regions.query; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +import com.livingworld.regions.Region; +import com.livingworld.regions.RegionCoordinate; +import com.livingworld.regions.RegionMetrics; +import com.livingworld.regions.cache.RegionCache; + +/** + * Runs ecosystem-focused queries against active region data. + */ +public final class RegionQueryEngine { + + private static final Comparator BY_COORDINATE = + Comparator.comparing((Region region) -> region.getCoordinate().dimensionId()) + .thenComparingInt(region -> region.getCoordinate().x()) + .thenComparingInt(region -> region.getCoordinate().z()); + + private final RegionCache cache; + + public RegionQueryEngine(RegionCache cache) { + if (cache == null) { + throw new IllegalArgumentException("cache must not be null"); + } + this.cache = cache; + } + + public Optional getRegion(RegionCoordinate coordinate) { + if (coordinate == null) { + throw new IllegalArgumentException("coordinate must not be null"); + } + return cache.get(coordinate); + } + + public List getRegionsInRadius(RegionCoordinate center, int radius) { + if (center == null) { + throw new IllegalArgumentException("center must not be null"); + } + if (radius < 0) { + throw new IllegalArgumentException("radius must be non-negative"); + } + + return cache.allActive().stream() + .filter(region -> region.getCoordinate().dimensionId().equals(center.dimensionId())) + .filter(region -> Math.abs((long) region.getCoordinate().x() - center.x()) <= radius) + .filter(region -> Math.abs((long) region.getCoordinate().z() - center.z()) <= radius) + .sorted(BY_COORDINATE) + .toList(); + } + + public List getRegionsWithActiveEcosystemEvent() { + return cache.allActive().stream() + .filter(region -> region.getFlags().isHasActiveEcosystemEvent()) + .sorted(BY_COORDINATE) + .toList(); + } + + public List getRegionsAbovePollution(double threshold) { + validateMetricThreshold(threshold); + return cache.allActive().stream() + .filter(region -> region.getMetrics().getPollutionScore() > threshold) + .sorted(BY_COORDINATE) + .toList(); + } + + public List getRegionsBelowSoilQuality(double threshold) { + validateMetricThreshold(threshold); + return cache.allActive().stream() + .filter(region -> region.getMetrics().getSoilQuality() < threshold) + .sorted(BY_COORDINATE) + .toList(); + } + + private static void validateMetricThreshold(double threshold) { + if (!Double.isFinite(threshold) + || threshold < RegionMetrics.MIN_VALUE + || threshold > RegionMetrics.MAX_VALUE) { + throw new IllegalArgumentException("threshold must be finite and in range [0, 100]"); + } + } +} diff --git a/src/test/java/com/livingworld/regions/query/RegionQueryEngineTest.java b/src/test/java/com/livingworld/regions/query/RegionQueryEngineTest.java new file mode 100644 index 0000000..dec3f00 --- /dev/null +++ b/src/test/java/com/livingworld/regions/query/RegionQueryEngineTest.java @@ -0,0 +1,70 @@ +package com.livingworld.regions.query; + +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.regions.Region; +import com.livingworld.regions.RegionCoordinate; +import com.livingworld.regions.RegionFactory; +import com.livingworld.regions.cache.RegionCache; + +class RegionQueryEngineTest { + + private final RegionFactory factory = new RegionFactory(); + private final RegionCache cache = new RegionCache(); + private final RegionQueryEngine queries = new RegionQueryEngine(cache); + + @Test + void radiusQueryUsesRegionCoordinatesAndCurrentDimension() { + Region center = add("minecraft:overworld", 0, 0); + add("minecraft:overworld", 1, 1); + add("minecraft:overworld", 2, 0); + add("minecraft:the_nether", 0, 0); + + assertEquals( + List.of(center.getCoordinate(), new RegionCoordinate("minecraft:overworld", 1, 1)), + queries.getRegionsInRadius(center.getCoordinate(), 1).stream() + .map(Region::getCoordinate) + .toList()); + } + + @Test + void ecosystemQueriesUseFlagsAndMetrics() { + Region affected = add("minecraft:overworld", 0, 0); + affected.getFlags().setHasActiveEcosystemEvent(true); + affected.getMetrics().setPollutionScore(80); + affected.getMetrics().setSoilQuality(20); + + Region healthy = add("minecraft:overworld", 1, 0); + healthy.getMetrics().setPollutionScore(10); + healthy.getMetrics().setSoilQuality(80); + + assertEquals(List.of(affected), queries.getRegionsWithActiveEcosystemEvent()); + assertEquals(List.of(affected), queries.getRegionsAbovePollution(50)); + assertEquals(List.of(affected), queries.getRegionsBelowSoilQuality(50)); + } + + @Test + void validatesArguments() { + assertThrows(IllegalArgumentException.class, + () -> new RegionQueryEngine(null)); + assertThrows(IllegalArgumentException.class, + () -> queries.getRegion(null)); + assertThrows(IllegalArgumentException.class, + () -> queries.getRegionsInRadius(new RegionCoordinate("minecraft:overworld", 0, 0), -1)); + assertThrows(IllegalArgumentException.class, + () -> queries.getRegionsAbovePollution(Double.NaN)); + assertThrows(IllegalArgumentException.class, + () -> queries.getRegionsBelowSoilQuality(101)); + } + + private Region add(String dimensionId, int x, int z) { + Region region = factory.createNewRegion(new RegionCoordinate(dimensionId, x, z), 0); + cache.put(region); + return region; + } +}