feat(save): SaveService disk persistence (atomic write, list, load)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user