Add file-backed region persistence

This commit is contained in:
George
2026-06-07 14:08:44 +01:00
parent 69d60f7c13
commit 96747a37db
4 changed files with 500 additions and 2 deletions
@@ -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<RegionCoordinate, Region> 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<Region> 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<SaveMetadata> 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.
}
}
}
@@ -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) {
@@ -53,4 +53,19 @@ public class RegionStorage {
}
persistenceService.saveRegion(region);
}
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();
}
}
@@ -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<Region> 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);
}
}