afterglow/progress.pyΒΆ

Part of Afterglow.

  1"""Afterglow save / progression layer (plain JSON).
  2
  3Why JSON and not ``simvx.core.save_manager.SaveManager``:
  4
  5``SaveManager`` snapshots ``Property(persist=True)`` values off a live ``Node``
  6tree and pickles them. Afterglow's progress is free-form, key-addressed data:
  7per-room best times keyed by ``(world_id, room_index)``, completion/shard flags,
  8a deaths counter, an unlocked-worlds set, and an options dict (volumes, toggles,
  9key rebinds). None of that lives on a node, and pickle's "unpickling executes
 10arbitrary code" trust boundary is a poor fit for a file the player may copy
 11between machines. SimVX only mandates ``.py`` for *scenes*; save files may use
 12any serialization, so a single human-diffable JSON document is the cleanest fit:
 13no tree to walk, no Property descriptors to mirror, and a forward-compatible
 14schema (unknown keys are tolerated on load).
 15
 16Writes are atomic (temp + ``fsync`` + ``os.replace``) so a crash mid-save never
 17corrupts the live file. A single ``.bak`` is kept for manual recovery.
 18
 19Coordinate / data contract this layer assumes (and nothing more):
 20  * a "room" is addressed by ``(world_id: str, room_index: int)``;
 21  * a "world" is addressed by ``world_id: str`` and has a known room count so we
 22    can tell when its last room was beaten (passed to ``record_room_result`` as
 23    ``world_room_count`` so this layer needs no import of ``rooms_data``);
 24  * worlds unlock in the order given by ``WORLD_ORDER`` (first world always
 25    unlocked); beating the last room of a world unlocks the next one.
 26"""
 27
 28from __future__ import annotations
 29
 30import json
 31import logging
 32import os
 33from pathlib import Path
 34from typing import Any
 35
 36log = logging.getLogger(__name__)
 37
 38#: Bump when the on-disk envelope shape changes incompatibly.
 39SAVE_VERSION = 1
 40
 41#: Canonical world unlock order. Mirrors ``rooms_data.WORLDS`` ids but is kept
 42#: here as a literal so the save layer carries zero sim/content imports. Keep in
 43#: sync with ``rooms_data`` if world ids ever change.
 44WORLD_ORDER: tuple[str, ...] = ("glade", "caverns", "spire")
 45
 46#: Defaults for every option key. ``set_option`` rejects unknown keys, so this
 47#: doubles as the option schema. ``key_rebinds`` maps an action name to a key
 48#: name (empty = engine default bindings).
 49DEFAULT_OPTIONS: dict[str, Any] = {
 50    # Ship a comfortable, quiet default mix: at 0 dB the layered music drone plus
 51    # overlapping SFX summed through Master was harsh and fatiguing. These are the
 52    # default slider positions; the player can raise any of them in Options.
 53    "master_volume": 0.4,
 54    "sfx_volume": 0.4,
 55    "music_volume": 0.22,
 56    "screenshake": True,
 57    "photosensitive_safe": False,
 58    "assist_mode": False,
 59    "assist_invincible": False,
 60    "assist_slow_time": False,
 61    "key_rebinds": {},
 62}
 63
 64
 65def default_save_path() -> Path:
 66    """Per-user save location, overridable via ``$AFTERGLOW_SAVE_DIR``.
 67
 68    Honours ``$XDG_DATA_HOME`` (falling back to ``~/.local/share``) on every
 69    platform; the demo has no platform-specific path needs beyond that.
 70    """
 71    override = os.environ.get("AFTERGLOW_SAVE_DIR")
 72    base = Path(override) if override else Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
 73    return base / "afterglow" / "progress.json"
 74
 75
 76def _room_key(world_id: str, room_index: int) -> str:
 77    """Flat JSON-safe key for a ``(world, room)`` pair (JSON keys are strings)."""
 78    return f"{world_id}:{room_index}"
 79
 80
 81class Progress:
 82    """Persistent player progression and options for Afterglow.
 83
 84    All mutators update in-memory state only; call :meth:`save` to persist.
 85    Construct with :meth:`load` to read an existing file (or get defaults).
 86    """
 87
 88    __slots__ = ("path", "rooms", "total_deaths", "unlocked_worlds", "options")
 89
 90    def __init__(self, path: Path | str | None = None) -> None:
 91        self.path = Path(path) if path is not None else default_save_path()
 92        # rooms[key] = {"best_time": float|None, "shard": bool, "completed": bool}
 93        self.rooms: dict[str, dict[str, Any]] = {}
 94        self.total_deaths: int = 0
 95        # First world is always reachable.
 96        self.unlocked_worlds: set[str] = {WORLD_ORDER[0]} if WORLD_ORDER else set()
 97        self.options: dict[str, Any] = _fresh_options()
 98
 99    # -- persistence ------------------------------------------------------
100
101    @classmethod
102    def load(cls, path: Path | str | None = None) -> Progress:
103        """Return a ``Progress`` loaded from ``path`` (defaults if absent/corrupt).
104
105        A missing file yields fresh defaults. A corrupt file is logged and also
106        yields defaults rather than crashing the game on launch.
107        """
108        prog = cls(path)
109        if not prog.path.exists():
110            return prog
111        try:
112            data = json.loads(prog.path.read_text(encoding="utf-8"))
113        except (OSError, ValueError) as exc:
114            log.error("Afterglow save at %s is unreadable (%s); using defaults", prog.path, exc)
115            return prog
116        prog._apply(data)
117        return prog
118
119    def _apply(self, data: dict[str, Any]) -> None:
120        if not isinstance(data, dict):
121            log.error("Afterglow save root is %s, expected dict; ignoring", type(data).__name__)
122            return
123        version = data.get("version", SAVE_VERSION)
124        if isinstance(version, int) and version > SAVE_VERSION:
125            log.warning("Afterglow save version %s is newer than %s; loading best-effort", version, SAVE_VERSION)
126        rooms = data.get("rooms")
127        if isinstance(rooms, dict):
128            for key, entry in rooms.items():
129                if isinstance(entry, dict):
130                    self.rooms[key] = {
131                        "best_time": entry.get("best_time"),
132                        "shard": bool(entry.get("shard", False)),
133                        "completed": bool(entry.get("completed", False)),
134                    }
135        self.total_deaths = int(data.get("total_deaths", 0))
136        unlocked = data.get("unlocked_worlds")
137        if isinstance(unlocked, list):
138            self.unlocked_worlds |= {str(w) for w in unlocked}
139        opts = data.get("options")
140        if isinstance(opts, dict):
141            for key, value in opts.items():
142                if key in DEFAULT_OPTIONS:
143                    self.options[key] = value
144
145    def to_dict(self) -> dict[str, Any]:
146        """Serialisable snapshot of the whole progress document."""
147        return {
148            "version": SAVE_VERSION,
149            "rooms": self.rooms,
150            "total_deaths": self.total_deaths,
151            "unlocked_worlds": sorted(self.unlocked_worlds),
152            "options": self.options,
153        }
154
155    def save(self) -> Path:
156        """Write the document to ``self.path`` atomically; return the path.
157
158        Rotates one ``.bak`` backup. Raises ``OSError`` on write failure (the
159        temp file is best-effort cleaned and the live file is untouched).
160        """
161        self.path.parent.mkdir(parents=True, exist_ok=True)
162        tmp = self.path.with_name(self.path.name + ".tmp")
163        payload = json.dumps(self.to_dict(), indent=2, sort_keys=True)
164        try:
165            with open(tmp, "w", encoding="utf-8") as fh:
166                fh.write(payload)
167                fh.flush()
168                os.fsync(fh.fileno())
169        except OSError:
170            tmp.unlink(missing_ok=True)
171            raise
172        if self.path.exists():
173            os.replace(self.path, self.path.with_name(self.path.name + ".bak"))
174        os.replace(tmp, self.path)
175        return self.path
176
177    # -- room results -----------------------------------------------------
178
179    def record_room_result(
180        self,
181        world_id: str,
182        room_index: int,
183        time: float,
184        shard: bool,
185        died_count: int = 0,
186        *,
187        world_room_count: int | None = None,
188    ) -> None:
189        """Record a room clear.
190
191        Best time only improves (``min``). ``shard`` and ``completed`` are
192        sticky (once true, stay true). ``died_count`` adds to the lifetime
193        deaths counter. If ``world_room_count`` is given and this is the last
194        room of the world (``room_index == world_room_count - 1``), the next
195        world in :data:`WORLD_ORDER` is unlocked.
196        """
197        key = _room_key(world_id, room_index)
198        entry = self.rooms.setdefault(key, {"best_time": None, "shard": False, "completed": False})
199        prev = entry["best_time"]
200        entry["best_time"] = time if prev is None else min(prev, time)
201        entry["shard"] = entry["shard"] or bool(shard)
202        entry["completed"] = True
203        if died_count:
204            self.total_deaths += int(died_count)
205        if world_room_count is not None and room_index >= world_room_count - 1:
206            self._unlock_next(world_id)
207
208    def _unlock_next(self, world_id: str) -> None:
209        if world_id not in WORLD_ORDER:
210            return
211        idx = WORLD_ORDER.index(world_id)
212        if idx + 1 < len(WORLD_ORDER):
213            self.unlocked_worlds.add(WORLD_ORDER[idx + 1])
214
215    # -- queries ----------------------------------------------------------
216
217    def best_time(self, world_id: str, room_index: int) -> float | None:
218        """Best recorded time for a room, or ``None`` if never cleared."""
219        entry = self.rooms.get(_room_key(world_id, room_index))
220        return entry["best_time"] if entry else None
221
222    def has_shard(self, world_id: str, room_index: int) -> bool:
223        entry = self.rooms.get(_room_key(world_id, room_index))
224        return bool(entry and entry["shard"])
225
226    def is_completed(self, world_id: str, room_index: int) -> bool:
227        entry = self.rooms.get(_room_key(world_id, room_index))
228        return bool(entry and entry["completed"])
229
230    def is_world_unlocked(self, world_id: str) -> bool:
231        return world_id in self.unlocked_worlds
232
233    # -- options ----------------------------------------------------------
234
235    def get_option(self, key: str) -> Any:
236        """Return an option value, falling back to its default."""
237        if key not in DEFAULT_OPTIONS:
238            raise KeyError(f"Unknown option {key!r}")
239        return self.options.get(key, DEFAULT_OPTIONS[key])
240
241    def set_option(self, key: str, value: Any) -> None:
242        """Set an option (rejecting unknown keys)."""
243        if key not in DEFAULT_OPTIONS:
244            raise KeyError(f"Unknown option {key!r}")
245        self.options[key] = value
246
247
248def _fresh_options() -> dict[str, Any]:
249    """Deep-ish copy of the option defaults (so the dict value isn't shared)."""
250    opts = dict(DEFAULT_OPTIONS)
251    opts["key_rebinds"] = dict(DEFAULT_OPTIONS["key_rebinds"])
252    return opts