nodes/save_io.pyΒΆ

Part of Klondike Solitaire.

 1"""JSON save/load for the live GameState.
 2
 3The engine's `simvx.core.save_manager.SaveManager` walks `Property(persist=True)`
 4descriptors. The Klondike state lives in mutable Python lists (deck order, move
 5history) -- not natural Property values -- so the payload is serialised by hand
 6to JSON, which also keeps the save file human-readable. The crash-safe write
 7itself is the engine's `simvx.core.io.atomic_write_text`.
 8"""
 9
10from __future__ import annotations
11
12import json
13from pathlib import Path
14
15from simvx.core.io import atomic_write_text
16
17from .game_state import GameState
18
19DEFAULT_SLOT = "klondike"
20
21
22def save_dir() -> Path:
23    """Where save files live. Resolved per call so tests can redirect it via cwd.
24
25    Nothing is created here: `atomic_write_text` makes the directory when the
26    first save is written, so merely asking whether a save exists leaves no
27    trace on disk.
28    """
29    return Path.cwd() / "saves"
30
31
32def save_path(slot: str = DEFAULT_SLOT) -> Path:
33    return save_dir() / f"{slot}.json"
34
35
36def has_save(slot: str = DEFAULT_SLOT) -> bool:
37    """Whether a save file exists, without paying to parse it."""
38    return save_path(slot).exists()
39
40
41def save_game(state: GameState, slot: str = DEFAULT_SLOT) -> Path:
42    """Write the save, keeping one rotated `.bak` of the previous one.
43
44    The rotation copies rather than moves, so the live save stays valid until
45    `atomic_write_text` swaps the new payload in.
46    """
47    target = save_path(slot)
48    if target.exists():
49        target.with_suffix(".json.bak").write_bytes(target.read_bytes())
50    return atomic_write_text(target, json.dumps(state.to_dict(), indent=2))
51
52
53def load_game(slot: str = DEFAULT_SLOT) -> GameState | None:
54    p = save_path(slot)
55    if not p.exists():
56        return None
57    try:
58        data = json.loads(p.read_text("utf-8"))
59    except (json.JSONDecodeError, OSError):
60        return None
61    try:
62        return GameState.from_dict(data)
63    except (KeyError, ValueError, TypeError):
64        return None
65
66
67def delete_save(slot: str = DEFAULT_SLOT) -> bool:
68    p = save_path(slot)
69    if p.exists():
70        p.unlink()
71        return True
72    return False