feat(save): SaveService disk persistence (atomic write, list, load)

This commit is contained in:
2026-06-01 14:44:32 +02:00
parent 3c7c9d2ad4
commit 6a68d7e2f5
2 changed files with 167 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
package thb.jeanluc.adventure.save;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import thb.jeanluc.adventure.game.GameSession;
import thb.jeanluc.adventure.loader.WorldLoader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
/**
* Reads and writes save slots as JSON files in a directory (default
* {@code ./saves}). One slot = one {@code <slug>.json}. Loading reloads the
* world from YAML and overlays the snapshot via {@link SaveCodec}.
*/
@Slf4j
public class SaveService {
private final Path dir;
private final ObjectMapper mapper = new ObjectMapper();
/** Uses the default {@code ./saves} directory. */
public SaveService() {
this(Path.of("saves"));
}
public SaveService(Path dir) {
this.dir = dir;
}
/** Slug used for the on-disk filename of a slot name. */
public static String slug(String slotName) {
String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_");
return s.isBlank() ? "save" : s;
}
/** Writes the session to {@code <slug>.json} atomically. */
public void save(GameSession session) {
SaveData data = SaveCodec.capture(session);
try {
Files.createDirectories(dir);
Path target = dir.resolve(slug(session.getSlotName()) + ".json");
Path tmp = dir.resolve(slug(session.getSlotName()) + ".json.tmp");
mapper.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), data);
try {
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException atomicFailed) {
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
throw new SaveException("Could not write save '" + session.getSlotName() + "': " + e.getMessage(), e);
}
}
/** Lists all readable slots, newest first; skips unreadable files. */
public List<SaveSlotInfo> list() {
if (!Files.isDirectory(dir)) {
return List.of();
}
List<SaveSlotInfo> out = new ArrayList<>();
try (Stream<Path> files = Files.list(dir)) {
for (Path f : files.filter(p -> p.toString().endsWith(".json")).toList()) {
try {
SaveData d = mapper.readValue(f.toFile(), SaveData.class);
String slug = f.getFileName().toString().replaceFirst("\\.json$", "");
out.add(new SaveSlotInfo(slug, d.slotName(), d.currentRoomId(),
d.turn(), d.savedAtEpochMillis()));
} catch (IOException badFile) {
log.warn("Skipping unreadable save file {}", f, badFile);
}
}
} catch (IOException e) {
log.warn("Could not list save directory {}", dir, e);
}
out.sort(Comparator.comparingLong(SaveSlotInfo::savedAtEpochMillis).reversed());
return out;
}
/** Loads a slot by slug: read JSON, reload world, overlay snapshot. */
public GameSession load(String slug) {
Path file = dir.resolve(slug + ".json");
SaveData data;
try {
data = mapper.readValue(file.toFile(), SaveData.class);
} catch (IOException e) {
throw new SaveException("Could not read save '" + slug + "': " + e.getMessage(), e);
}
if (data.schemaVersion() != SaveData.CURRENT_VERSION) {
throw new SaveException("Save '" + slug + "' uses an incompatible version ("
+ data.schemaVersion() + "); expected " + SaveData.CURRENT_VERSION + ".");
}
WorldLoader.LoadResult fresh = new WorldLoader().load();
GameSession session = new GameSession(fresh.world(), fresh.player(), data.slotName());
SaveCodec.apply(data, session);
return session;
}
}

View File

@@ -0,0 +1,63 @@
package thb.jeanluc.adventure.save;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import thb.jeanluc.adventure.game.GameSession;
import thb.jeanluc.adventure.loader.WorldLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SaveServiceTest {
private GameSession freshSession(String slot) {
WorldLoader.LoadResult r = new WorldLoader().load();
return new GameSession(r.world(), r.player(), slot);
}
@Test
void saveThenLoadReproducesState(@TempDir Path dir) {
SaveService svc = new SaveService(dir);
GameSession s = freshSession("my-game");
s.getState().set("power_on");
s.getPlayer().setGold(4);
s.setTurn(9);
svc.save(s);
GameSession loaded = svc.load("my-game");
assertThat(loaded.getState().isSet("power_on")).isTrue();
assertThat(loaded.getPlayer().getGold()).isEqualTo(4);
assertThat(loaded.getTurn()).isEqualTo(9);
assertThat(loaded.getSlotName()).isEqualTo("my-game");
}
@Test
void listReturnsSlotMetadata(@TempDir Path dir) {
SaveService svc = new SaveService(dir);
GameSession s = freshSession("Alpha Run");
s.setTurn(3);
svc.save(s);
List<SaveSlotInfo> slots = svc.list();
assertThat(slots).hasSize(1);
assertThat(slots.getFirst().slotName()).isEqualTo("Alpha Run");
assertThat(slots.getFirst().turn()).isEqualTo(3);
}
@Test
void listSkipsCorruptFiles(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("broken.json"), "{ not json");
assertThat(new SaveService(dir).list()).isEmpty();
}
@Test
void loadCorruptFileThrowsSaveException(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("bad.json"), "{ not json");
SaveService svc = new SaveService(dir);
assertThrows(SaveException.class, () -> svc.load("bad"));
}
}