diff --git a/src/main/java/com/livingworld/core/services/FileRegionPersistenceService.java b/src/main/java/com/livingworld/core/services/FileRegionPersistenceService.java new file mode 100644 index 0000000..3b35e14 --- /dev/null +++ b/src/main/java/com/livingworld/core/services/FileRegionPersistenceService.java @@ -0,0 +1,364 @@ +package com.livingworld.core.services; + +import com.livingworld.data.saved.SaveMetadata; +import com.livingworld.debug.DiagnosticCategory; +import com.livingworld.debug.LivingWorldLogger; +import com.livingworld.regions.Region; +import com.livingworld.regions.RegionCoordinate; +import com.livingworld.regions.RegionFlags; +import com.livingworld.regions.RegionLifecycleState; +import com.livingworld.regions.RegionMetrics; +import com.livingworld.regions.RegionModuleData; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Clock; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.UUID; + +/** + * File-backed persistence for the Volume 1 region model. + */ +public final class FileRegionPersistenceService implements PersistenceService { + + static final int SCHEMA_VERSION = 1; + + private static final String METADATA_FILE = "metadata.properties"; + private static final String REGIONS_DIRECTORY = "regions"; + + private final Path rootDirectory; + private final Path regionsDirectory; + private final String modVersion; + private final Clock clock; + private final Map dirtyRegions = new LinkedHashMap<>(); + private SaveMetadata metadata; + + public FileRegionPersistenceService(Path rootDirectory, String modVersion) { + this(rootDirectory, modVersion, Clock.systemUTC()); + } + + FileRegionPersistenceService(Path rootDirectory, String modVersion, Clock clock) { + if (rootDirectory == null) { + throw new IllegalArgumentException("rootDirectory must not be null"); + } + if (modVersion == null || modVersion.isBlank()) { + throw new IllegalArgumentException("modVersion must not be blank"); + } + if (clock == null) { + throw new IllegalArgumentException("clock must not be null"); + } + this.rootDirectory = rootDirectory; + this.regionsDirectory = rootDirectory.resolve(REGIONS_DIRECTORY); + this.modVersion = modVersion; + this.clock = clock; + initializeStorage(); + } + + @Override + public synchronized void markRegionDirty(Region region) { + requireRegion(region); + region.markDirty(); + dirtyRegions.put(region.getCoordinate(), region); + } + + @Override + public synchronized void saveDirtyRegions() { + for (Region region : java.util.List.copyOf(dirtyRegions.values())) { + saveRegion(region); + } + updateMetadata(); + } + + @Override + public synchronized Optional loadRegion(RegionCoordinate coordinate) { + if (coordinate == null) { + throw new IllegalArgumentException("coordinate must not be null"); + } + Path file = regionPath(coordinate); + if (!Files.exists(file)) { + return Optional.empty(); + } + + try (InputStream input = Files.newInputStream(file)) { + Properties properties = new Properties(); + properties.load(input); + Region region = decodeRegion(properties); + if (!coordinate.equals(region.getCoordinate())) { + throw new IllegalStateException( + "stored coordinate does not match requested coordinate"); + } + return Optional.of(region); + } catch (IOException | RuntimeException exception) { + quarantineCorruptFile(file, exception); + return Optional.empty(); + } + } + + @Override + public synchronized void saveRegion(Region region) { + requireRegion(region); + region.validate(); + + Properties properties = encodeRegion(region); + Path target = regionPath(region.getCoordinate()); + Path temporary = target.resolveSibling(target.getFileName() + ".tmp"); + try { + Files.createDirectories(regionsDirectory); + try (OutputStream output = Files.newOutputStream(temporary)) { + properties.store(output, "Living World region data"); + } + moveAtomically(temporary, target); + region.clearDirty(); + dirtyRegions.remove(region.getCoordinate()); + } catch (IOException exception) { + throw new IllegalStateException( + "Failed to save region " + region.getCoordinate(), exception); + } finally { + tryDelete(temporary); + } + } + + @Override + public synchronized void flushAll() { + saveDirtyRegions(); + } + + public synchronized int getDirtyRegionCount() { + return dirtyRegions.size(); + } + + private void initializeStorage() { + try { + Files.createDirectories(regionsDirectory); + metadata = loadMetadata().orElseGet(() -> { + long now = clock.millis(); + return new SaveMetadata(SCHEMA_VERSION, modVersion, now, now); + }); + if (metadata.schemaVersion() != SCHEMA_VERSION) { + throw new IllegalStateException( + "Unsupported save schema version: " + metadata.schemaVersion()); + } + writeMetadata(metadata); + } catch (IOException exception) { + throw new IllegalStateException( + "Failed to initialize persistence at " + rootDirectory, exception); + } + } + + private Optional loadMetadata() throws IOException { + Path path = rootDirectory.resolve(METADATA_FILE); + if (!Files.exists(path)) { + return Optional.empty(); + } + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(path)) { + properties.load(input); + } + return Optional.of(new SaveMetadata( + requiredInt(properties, "schemaVersion"), + required(properties, "modVersion"), + requiredLong(properties, "createdAt"), + requiredLong(properties, "updatedAt"))); + } + + private void updateMetadata() { + metadata = new SaveMetadata( + metadata.schemaVersion(), + modVersion, + metadata.createdAt(), + clock.millis()); + try { + writeMetadata(metadata); + } catch (IOException exception) { + throw new IllegalStateException("Failed to update save metadata", exception); + } + } + + private void writeMetadata(SaveMetadata value) throws IOException { + Properties properties = new Properties(); + properties.setProperty("schemaVersion", Integer.toString(value.schemaVersion())); + properties.setProperty("modVersion", value.modVersion()); + properties.setProperty("createdAt", Long.toString(value.createdAt())); + properties.setProperty("updatedAt", Long.toString(value.updatedAt())); + Path target = rootDirectory.resolve(METADATA_FILE); + Path temporary = target.resolveSibling(target.getFileName() + ".tmp"); + try (OutputStream output = Files.newOutputStream(temporary)) { + properties.store(output, "Living World save metadata"); + } + moveAtomically(temporary, target); + } + + private Properties encodeRegion(Region region) { + Properties properties = new Properties(); + properties.setProperty("schemaVersion", Integer.toString(SCHEMA_VERSION)); + properties.setProperty("id", region.getId().toString()); + properties.setProperty("dimensionId", region.getCoordinate().dimensionId()); + properties.setProperty("regionX", Integer.toString(region.getCoordinate().x())); + properties.setProperty("regionZ", Integer.toString(region.getCoordinate().z())); + properties.setProperty("lifecycleState", region.getLifecycleState().name()); + properties.setProperty( + "createdAtSimulationTick", + Long.toString(region.getCreatedAtSimulationTick())); + properties.setProperty( + "lastUpdatedSimulationTick", + Long.toString(region.getLastUpdatedSimulationTick())); + + RegionFlags flags = region.getFlags(); + properties.setProperty("flag.playerActivity", Boolean.toString(flags.isHasPlayerActivity())); + properties.setProperty("flag.highPollution", Boolean.toString(flags.isHasHighPollution())); + properties.setProperty("flag.lowSoilQuality", Boolean.toString(flags.isHasLowSoilQuality())); + properties.setProperty( + "flag.activeEcosystemEvent", + Boolean.toString(flags.isHasActiveEcosystemEvent())); + properties.setProperty( + "flag.forceLoaded", + Boolean.toString(flags.isForceLoadedBySimulation())); + properties.setProperty("flag.corrupted", Boolean.toString(flags.isCorrupted())); + + RegionMetrics metrics = region.getMetrics(); + properties.setProperty( + "metric.ecosystemHealth", + Double.toString(metrics.getEcosystemHealth())); + properties.setProperty( + "metric.pollutionScore", + Double.toString(metrics.getPollutionScore())); + properties.setProperty("metric.soilQuality", Double.toString(metrics.getSoilQuality())); + properties.setProperty("metric.waterQuality", Double.toString(metrics.getWaterQuality())); + properties.setProperty( + "metric.vegetationPressure", + Double.toString(metrics.getVegetationPressure())); + properties.setProperty( + "metric.resourceDepletion", + Double.toString(metrics.getResourceDepletion())); + properties.setProperty( + "metric.recoveryPressure", + Double.toString(metrics.getRecoveryPressure())); + return properties; + } + + private Region decodeRegion(Properties properties) { + int schemaVersion = requiredInt(properties, "schemaVersion"); + if (schemaVersion != SCHEMA_VERSION) { + throw new IllegalStateException( + "Unsupported region schema version: " + schemaVersion); + } + + RegionFlags flags = new RegionFlags(); + flags.setHasPlayerActivity(requiredBoolean(properties, "flag.playerActivity")); + flags.setHasHighPollution(requiredBoolean(properties, "flag.highPollution")); + flags.setHasLowSoilQuality(requiredBoolean(properties, "flag.lowSoilQuality")); + flags.setHasActiveEcosystemEvent( + requiredBoolean(properties, "flag.activeEcosystemEvent")); + flags.setForceLoadedBySimulation(requiredBoolean(properties, "flag.forceLoaded")); + flags.setCorrupted(requiredBoolean(properties, "flag.corrupted")); + + RegionMetrics metrics = new RegionMetrics(); + metrics.setEcosystemHealth(requiredDouble(properties, "metric.ecosystemHealth")); + metrics.setPollutionScore(requiredDouble(properties, "metric.pollutionScore")); + metrics.setSoilQuality(requiredDouble(properties, "metric.soilQuality")); + metrics.setWaterQuality(requiredDouble(properties, "metric.waterQuality")); + metrics.setVegetationPressure(requiredDouble(properties, "metric.vegetationPressure")); + metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion")); + metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure")); + + Region region = new Region( + UUID.fromString(required(properties, "id")), + new RegionCoordinate( + required(properties, "dimensionId"), + requiredInt(properties, "regionX"), + requiredInt(properties, "regionZ")), + RegionLifecycleState.valueOf(required(properties, "lifecycleState")), + requiredLong(properties, "createdAtSimulationTick"), + requiredLong(properties, "lastUpdatedSimulationTick"), + false, + flags, + metrics, + new RegionModuleData()); + region.validate(); + return region; + } + + private Path regionPath(RegionCoordinate coordinate) { + String dimension = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(coordinate.dimensionId().getBytes(StandardCharsets.UTF_8)); + return regionsDirectory.resolve( + dimension + "_" + coordinate.x() + "_" + coordinate.z() + ".properties"); + } + + private void quarantineCorruptFile(Path file, Exception exception) { + Path quarantined = file.resolveSibling( + file.getFileName() + ".corrupt-" + clock.millis()); + try { + Files.move(file, quarantined, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException moveException) { + exception.addSuppressed(moveException); + } + LivingWorldLogger.warn( + DiagnosticCategory.PERSISTENCE, + "Quarantined unreadable region file " + file + ": " + exception.getMessage()); + } + + private static String required(Properties properties, String key) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required property: " + key); + } + return value; + } + + private static int requiredInt(Properties properties, String key) { + return Integer.parseInt(required(properties, key)); + } + + private static long requiredLong(Properties properties, String key) { + return Long.parseLong(required(properties, key)); + } + + private static double requiredDouble(Properties properties, String key) { + return Double.parseDouble(required(properties, key)); + } + + private static boolean requiredBoolean(Properties properties, String key) { + String value = required(properties, key); + if (!value.equals("true") && !value.equals("false")) { + throw new IllegalStateException("Invalid boolean property " + key + ": " + value); + } + return Boolean.parseBoolean(value); + } + + private static void requireRegion(Region region) { + if (region == null) { + throw new IllegalArgumentException("region must not be null"); + } + } + + private static void moveAtomically(Path source, Path target) throws IOException { + try { + Files.move( + source, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void tryDelete(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // The target save already succeeded or reported its own failure. + } + } +} diff --git a/src/main/java/com/livingworld/regions/RegionManager.java b/src/main/java/com/livingworld/regions/RegionManager.java index ef9a274..b9b639d 100644 --- a/src/main/java/com/livingworld/regions/RegionManager.java +++ b/src/main/java/com/livingworld/regions/RegionManager.java @@ -56,6 +56,9 @@ public final class RegionManager implements com.livingworld.core.simulation.Regi } regionCache.put(region); + if (region.isDirty()) { + regionStorage.markDirty(region); + } return region; } @@ -90,7 +93,15 @@ public final class RegionManager implements com.livingworld.core.simulation.Regi throw new IllegalArgumentException( "a different region instance is already cached for " + region.getCoordinate()); } - region.markDirty(); + regionStorage.markDirty(region); + } + + public void saveDirtyRegions() { + regionStorage.saveDirtyRegions(); + } + + public void flushAll() { + regionStorage.flushAll(); } public void unloadRegion(RegionCoordinate coordinate) { diff --git a/src/main/java/com/livingworld/regions/RegionStorage.java b/src/main/java/com/livingworld/regions/RegionStorage.java index 4d75483..92259d5 100644 --- a/src/main/java/com/livingworld/regions/RegionStorage.java +++ b/src/main/java/com/livingworld/regions/RegionStorage.java @@ -53,4 +53,19 @@ public class RegionStorage { } persistenceService.saveRegion(region); } -} \ No newline at end of file + + public void markDirty(Region region) { + if (region == null) { + throw new IllegalArgumentException("Region must not be null"); + } + persistenceService.markRegionDirty(region); + } + + public void saveDirtyRegions() { + persistenceService.saveDirtyRegions(); + } + + public void flushAll() { + persistenceService.flushAll(); + } +} diff --git a/src/test/java/com/livingworld/core/services/FileRegionPersistenceServiceTest.java b/src/test/java/com/livingworld/core/services/FileRegionPersistenceServiceTest.java new file mode 100644 index 0000000..9cf214b --- /dev/null +++ b/src/test/java/com/livingworld/core/services/FileRegionPersistenceServiceTest.java @@ -0,0 +1,108 @@ +package com.livingworld.core.services; + +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.regions.Region; +import com.livingworld.regions.RegionCoordinate; +import com.livingworld.regions.RegionFactory; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileRegionPersistenceServiceTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.ofEpochMilli(1_000L), ZoneOffset.UTC); + + @TempDir + Path temporaryDirectory; + + @Test + void roundTripsCoreRegionState() { + FileRegionPersistenceService service = service(); + Region original = new RegionFactory().createNewRegion( + new RegionCoordinate("minecraft:overworld", -2, 3), + 12); + original.updateLastSimulatedTick(42); + original.getFlags().setHasHighPollution(true); + original.getFlags().setForceLoadedBySimulation(true); + original.getMetrics().setEcosystemHealth(37.5); + original.getMetrics().setWaterQuality(22.25); + + service.saveRegion(original); + Optional loaded = service.loadRegion(original.getCoordinate()); + + assertTrue(loaded.isPresent()); + Region restored = loaded.orElseThrow(); + assertEquals(original.getId(), restored.getId()); + assertEquals(original.getCoordinate(), restored.getCoordinate()); + assertEquals(original.getLifecycleState(), restored.getLifecycleState()); + assertEquals(12, restored.getCreatedAtSimulationTick()); + assertEquals(42, restored.getLastUpdatedSimulationTick()); + assertTrue(restored.getFlags().isHasHighPollution()); + assertTrue(restored.getFlags().isForceLoadedBySimulation()); + assertEquals(37.5, restored.getMetrics().getEcosystemHealth()); + assertEquals(22.25, restored.getMetrics().getWaterQuality()); + assertFalse(restored.isDirty()); + } + + @Test + void dirtyQueueIsPersistedAndCleared() { + FileRegionPersistenceService service = service(); + Region region = new RegionFactory().createNewRegion( + new RegionCoordinate("minecraft:overworld", 0, 0), + 0); + + service.markRegionDirty(region); + assertEquals(1, service.getDirtyRegionCount()); + + service.saveDirtyRegions(); + + assertEquals(0, service.getDirtyRegionCount()); + assertFalse(region.isDirty()); + assertTrue(service.loadRegion(region.getCoordinate()).isPresent()); + } + + @Test + void corruptRegionIsQuarantinedInsteadOfCrashingLoad() throws Exception { + FileRegionPersistenceService service = service(); + Region region = new RegionFactory().createNewRegion( + new RegionCoordinate("minecraft:overworld", 1, 0), + 0); + service.saveRegion(region); + Path regionFile; + try (var files = Files.list(temporaryDirectory.resolve("regions"))) { + regionFile = files.findFirst().orElseThrow(); + } + Files.writeString(regionFile, "not=a-valid-region"); + + assertTrue(service.loadRegion(region.getCoordinate()).isEmpty()); + try (var files = Files.list(temporaryDirectory.resolve("regions"))) { + assertTrue(files.anyMatch(path -> path.getFileName().toString().contains(".corrupt-"))); + } + } + + @Test + void metadataSurvivesServiceRestart() { + service().flushAll(); + + FileRegionPersistenceService restarted = service(); + + assertTrue(Files.exists(temporaryDirectory.resolve("metadata.properties"))); + assertEquals(0, restarted.getDirtyRegionCount()); + } + + private FileRegionPersistenceService service() { + return new FileRegionPersistenceService( + temporaryDirectory, + "0.1.0", + CLOCK); + } +}