Python 156 lines
"""Tiny progress + preferences save file.
One JSON document at ``~/.the-way-out/save.json``::
{
"completed": ["level_1", ...], # beaten level ids
"times": {"level_1": 42.7}, # best clear time, seconds
"settings": {"sound": true} # persisted prefs
}
Survives reclones, keeps no per-run state. The level menu reads
``completed``/``times`` for the ✓ marks and best-time line,
``LevelManager`` writes both when a run finishes, and the Settings
menu reads/writes ``settings``.
Every public function goes through one ``_load``/``_write`` pair so a
write of one section never clobbers another, and any I/O or shape
error degrades to "no save data" / "save skipped" — a weird FS never
crashes the game.
Legacy migration: older saves stored integer indices (``0/1/2``) in
``completed`` before the catalog refactor; those map to
``level_1/2/3`` on read so a returning player keeps their checkmarks.
"""
import json
import os
from settings import SAVE_DIR, SAVE_FILE
# How a legacy int index maps to a string id. Indices 0..2 were the
# only ones ever shipped, so a flat lookup is enough.
_LEGACY_INDEX_TO_ID = {0: "level_1", 1: "level_2", 2: "level_3"}
def _load():
"""The whole save document as a dict. Empty dict on any error or
if the file holds something other than an object."""
try:
with open(SAVE_FILE) as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
return data if isinstance(data, dict) else {}
def _write(data):
"""Rewrite the whole file atomically. Silent no-op on FS errors so
a write-protected home never crashes the player out of a victory
screen. The tmp+rename keeps the previous file intact if the
process is killed mid-write — without it, a partial JSON reads
back as ``{}`` and silently wipes all progress."""
try:
SAVE_DIR.mkdir(parents=True, exist_ok=True)
tmp = SAVE_FILE.with_name(SAVE_FILE.name + '.tmp')
with open(tmp, 'w') as f:
json.dump(data, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, SAVE_FILE)
except OSError as e:
print(f"the-way-out: could not save progress ({e})")
# --- completion ---------------------------------------------------------
def _parse_completed(data):
"""Apply legacy int-index migration to ``data['completed']`` and
return a set of string level ids."""
out = set()
for item in data.get('completed', []):
if isinstance(item, str):
out.add(item)
elif isinstance(item, bool):
continue # bool is an int subclass — never a valid index
elif isinstance(item, int) and item in _LEGACY_INDEX_TO_ID:
out.add(_LEGACY_INDEX_TO_ID[item])
return out
def load_completed():
"""Return a ``set`` of completed level ids (strings). Empty set on
any error or missing file."""
return _parse_completed(_load())
def mark_complete(level_id):
"""Add ``level_id`` to the completed set and rewrite the file,
preserving ``times``/``settings``."""
if not isinstance(level_id, str):
return # guard against accidental int passthrough
data = _load()
completed = _parse_completed(data)
if level_id in completed:
return
completed.add(level_id)
data['completed'] = sorted(completed)
_write(data)
# --- best times ---------------------------------------------------------
def _parse_times(data):
"""Return a dict of valid level id → float seconds from
``data['times']``, dropping anything malformed so a hand-edited
file can't crash the menu."""
out = {}
raw = data.get('times', {})
if isinstance(raw, dict):
for k, v in raw.items():
if (isinstance(k, str) and isinstance(v, (int, float))
and not isinstance(v, bool) and v > 0):
out[k] = float(v)
return out
def load_times():
"""Map of level id → best clear time in seconds. Skips anything
malformed so a hand-edited file can't crash the menu."""
return _parse_times(_load())
def record_time(level_id, seconds):
"""Store ``seconds`` as the best time for ``level_id``, but only if
it beats (or sets) the existing record."""
if not isinstance(level_id, str) or seconds <= 0:
return
data = _load()
times = _parse_times(data)
best = times.get(level_id)
if best is not None and best <= seconds:
return
times[level_id] = float(seconds)
data['times'] = times
_write(data)
# --- preferences --------------------------------------------------------
def load_settings():
"""Persisted preference dict (e.g. ``{"sound": True}``). Empty dict
if unset or malformed."""
raw = _load().get('settings', {})
return raw if isinstance(raw, dict) else {}
def set_setting(key, value):
"""Set one preference key, preserving the rest of the document."""
data = _load()
settings = data.get('settings')
if not isinstance(settings, dict):
settings = {}
settings[key] = value
data['settings'] = settings
_write(data)