shrike/save.py¶

Part of SHRIKE.

  1"""Persistence: the permanent meta profile and the one-slot suspend save.
  2
  3Two files live under the player's data directory, both written through the
  4engine's :class:`~simvx.core.SaveManager` so they inherit its atomic write and
  5two-deep backup rotation:
  6
  7``profile.sav``
  8    Everything section 9 of the design says persists: Cores and the doctrine
  9    tree, quills and Fletchings, Lure fragments and quill-tech blueprints,
 10    unlocked hulls, the Codex, module-pool expansions, Hunt Ranks, the
 11    milestone flags that drive the first five runs, and a personal best score
 12    per hull. Written whenever a run ends and whenever the tree is respent.
 13
 14``suspend.sav``
 15    One slot, holding a full mid-sector run state. Written when the player
 16    quits mid-run and **deleted the moment it is read**, so it catches the run
 17    that outgrew the lunch break without ever becoming a save-scum button. The
 18    delete takes the rotated backups with it: leaving ``suspend.sav.bak`` on
 19    disk would hand back the very undo the single slot exists to deny.
 20
 21Both payloads are plain dictionaries carried on one :class:`SaveRecord`
 22Property, not a snapshot of the live scene tree. Runs are permadeath and a
 23sector is regenerated from its seed, so what has to survive a quit is a small,
 24explicit state description rather than a serialised world.
 25
 26Versioning
 27==========
 28
 29There are two independent version numbers and they answer different questions.
 30``SaveRecord.__save_version__`` versions the *envelope* (one node, one dict
 31Property) and is expected never to move. ``payload["schema_version"]`` versions
 32the *game* schema below, and is the one that changes as the game grows.
 33:data:`PROFILE_MIGRATIONS` and :data:`SUSPEND_MIGRATIONS` map a version to the
 34function that lifts a payload one step forward; loading walks the chain until
 35the payload is current, and refuses a payload from a newer build outright.
 36
 37Missing keys are filled from the current defaults on every load, so a migration
 38is only needed when a key is *renamed* or its meaning changes, never when one
 39is added.
 40
 41Profile schema (version 1)
 42==========================
 43
 44.. code-block:: text
 45
 46    schema_version: int
 47    cores:          int                      Cores banked, the meta currency
 48    doctrine:       owned: [node_id]         bought doctrine nodes
 49                    active_keystones: [id]   at most balance.DOCTRINE_MAX_ACTIVE_KEYSTONES
 50    quills:         int                      sheared from the hide or forged
 51    fletchings:     int                      balance.FLETCHINGS_PER_QUILL forge one quill
 52    runs_since_last_quill: int               drives the herald drop-rate ramp
 53    lure:           fragments: {biome: int}  log-fragment caches, per biome type
 54                    assembled: bool
 55    quill_tech:     [blueprint_id]           mirror-lantern, quill-tipped rounds
 56    hulls:          unlocked: [hull_id]      feat-gated trophies
 57                    selected: hull_id
 58    hunt_rank:      int                      one ascension tier per Roost kill
 59    roost:          phases_defeated: int     phases stay defeated across attempts
 60                    attempts: int
 61    codex:          log_fragments / signal_events / enemies / biomes: [id]
 62                    "enemies" is also onboarding's first-sighting record, one
 63                    entry per archetype this profile has ever been shown
 64    lessons:        {lesson_id: bool}        onboarding's once-per-profile cards
 65    module_pool:    purchased_rares: [id]    first purchase of a rare module...
 66                    expansions: [id]         ...adds its sibling to the world pool
 67    milestones:     {milestone_id: bool}     the first-five-runs bounty flags
 68    kit:            kit_id                   the Doctrine Kit this profile took
 69                                             at its first dock; "" until then
 70    best_scores:    {hull_id: int}           per-hull personal bests
 71    runs_started:   int
 72    runs_extracted: int
 73
 74Suspend schema (version 1)
 75==========================
 76
 77Nine sections, each a dictionary; :func:`blank_suspend` returns the template
 78that ``flow.py`` fills in. ``run`` carries the run identity and the sector seed,
 79``ship`` the hull, plane state and fittings (weapons by hardpoint with their
 80magazines, the ammo locker, modules by socket, and any stowed weapons, so a
 81mid-run purchase survives the quit), ``resources`` the four survival meters
 82plus carried scrap, ``signature`` / ``notoriety`` / ``hunter`` the three
 83pressure gauges and the arrival ladder's position, ``sector`` the per-object
 84harvest progress that a seed cannot reproduce, ``chart`` the route and the Wake
 85front, and ``ledger`` the run tally the death and extraction screens read.
 86"""
 87
 88from __future__ import annotations
 89
 90import logging
 91import os
 92from collections.abc import Callable
 93from copy import deepcopy
 94from datetime import UTC, datetime
 95from pathlib import Path
 96
 97from simvx.core import Node, Property, SaveManager, Signal
 98
 99from . import balance
100
101log = logging.getLogger(__name__)
102
103# ============================================================================
104# Slots, versions and defaults
105# ============================================================================
106
107#: Slot names, which become ``<slot>.sav`` inside :func:`data_dir`.
108PROFILE_SLOT = "profile"
109SUSPEND_SLOT = "suspend"
110
111#: Game-schema versions, stored as ``payload["schema_version"]``.
112PROFILE_SCHEMA_VERSION = 1
113SUSPEND_SCHEMA_VERSION = 1
114
115#: The starter hull, unlocked from the first boot. The other three in
116#: ``balance.HULL_SOCKETS`` are feat-gated trophies.
117STARTER_HULL = "vagrant"
118
119#: Milestone ids paired with their Core bounty. The ids are named here because
120#: balance.py prices the bounties without naming them; flow.py reads this map
121#: to pay a milestone the first time its flag flips.
122MILESTONE_CORES: dict[str, int] = {
123    "first_extraction": balance.MILESTONE_FIRST_EXTRACTION,
124    "first_vault": balance.MILESTONE_FIRST_VAULT,
125    "first_elite_kill": balance.MILESTONE_FIRST_ELITE_KILL,
126    "first_act3_entry": balance.MILESTONE_FIRST_ACT3_ENTRY,
127    "first_arrival_survived": balance.MILESTONE_FIRST_ARRIVAL_SURVIVED,
128}
129
130#: Codex categories, one list of seen ids each.
131CODEX_CATEGORIES = ("log_fragments", "signal_events", "enemies", "biomes")
132
133#: The suspend save's top-level sections. Every one must be present in a
134#: run state handed to :meth:`SaveSystem.write_suspend`.
135SUSPEND_SECTIONS = (
136    "run",
137    "ship",
138    "resources",
139    "signature",
140    "notoriety",
141    "hunter",
142    "sector",
143    "chart",
144    "ledger",
145)
146
147
148class SaveError(RuntimeError):
149    """Base class for every failure this module raises."""
150
151
152class SaveSchemaError(SaveError):
153    """A payload's ``schema_version`` cannot be brought to the current one."""
154
155
156class ProfileLoadError(SaveError):
157    """The profile exists but could not be read.
158
159    Raised rather than swallowed: silently handing back a fresh profile would
160    erase a player's whole progression at boot. The rotated ``.bak`` and
161    ``.bak2`` files beside it are the manual recovery path, and the message
162    names them.
163    """
164
165
166def default_profile() -> dict:
167    """A fresh meta profile: nothing unlocked, nothing banked."""
168    return {
169        "schema_version": PROFILE_SCHEMA_VERSION,
170        "cores": 0,
171        "doctrine": {"owned": [], "active_keystones": []},
172        "quills": 0,
173        "fletchings": 0,
174        "runs_since_last_quill": 0,
175        "lure": {"fragments": {}, "assembled": False},
176        "quill_tech": [],
177        "hulls": {"unlocked": [STARTER_HULL], "selected": STARTER_HULL},
178        "hunt_rank": 0,
179        "roost": {"phases_defeated": 0, "attempts": 0},
180        "codex": {category: [] for category in CODEX_CATEGORIES},
181        "lessons": {},
182        "module_pool": {"purchased_rares": [], "expansions": []},
183        "milestones": dict.fromkeys(MILESTONE_CORES, False),
184        "kit": "",
185        "best_scores": {},
186        "runs_started": 0,
187        "runs_extracted": 0,
188    }
189
190
191def blank_suspend() -> dict:
192    """The suspend template, with every section present and empty.
193
194    ``flow.py`` starts from this and fills it, so a section added here reaches
195    every writer without touching the writer.
196    """
197    return {
198        "schema_version": SUSPEND_SCHEMA_VERSION,
199        "saved_at": "",
200        "run": {
201            "run_number": 0,
202            "seed": 0,
203            "elapsed_s": 0.0,
204            "kit": "",
205            "keystones": [],
206            "assists": {},
207            "sector_index": 0,
208            "sector_seed": 0,
209            "biome_id": "",
210        },
211        "ship": {
212            "hull_id": STARTER_HULL,
213            "hull": balance.HULL_MAX_STARTER,
214            "hull_max": balance.HULL_MAX_STARTER,
215            "open_breaches": 0,
216            "position": [0.0, 0.0],
217            "velocity": [0.0, 0.0],
218            "heading": 0.0,
219            "shield_arc_centre": 0.0,
220            "shield_charge": 0.0,
221            # The fittings: what is bolted where, with the magazines as they
222            # stood. Empty lists mean "keep the rebuilt starter fit", which is
223            # also what a payload written before these were captured says.
224            "weapons": [],
225            "ammo_boxes": {},
226            "modules": [],
227            "stowed_weapons": [],
228        },
229        "resources": {
230            "capacitor": 0.0,
231            "capacitor_max": 0.0,
232            "fuel": 0.0,
233            "o2": 0.0,
234            "scrap": 0.0,
235            "cores_banked_this_run": 0.0,
236        },
237        "signature": {"value": 0.0, "act": 1, "silent_running": False},
238        "notoriety": {"value": 0},
239        "hunter": {
240            "arrivals_this_run": 0,
241            "state": "absent",
242            "telegraph_stage": "",
243            "seconds_to_arrival": 0.0,
244            "quills_this_run": 0,
245        },
246        "sector": {"deposits": [], "wrecks": [], "vaults": [], "hazards": [], "seen_events": [], "event_draws": 0},
247        "chart": {
248            "seed": 0,
249            "current_node": "",
250            "visited": [],
251            "wake_column": 0,
252            "jumps_taken": 0,
253        },
254        "ledger": {"scrap_earned": 0.0, "kills": 0, "milestones": [], "deepest_sector": 0},
255    }
256
257
258# ============================================================================
259# Migrations
260# ============================================================================
261
262#: ``{from_version: upgrade}``. Each entry lifts a payload exactly one version;
263#: the loader walks the chain. Adding a key needs no entry, because loading
264#: fills unknown keys from the current defaults. Renaming one does.
265PROFILE_MIGRATIONS: dict[int, Callable[[dict], dict]] = {}
266
267#: The same, for the suspend slot. A suspend save that cannot be migrated is
268#: discarded rather than surfaced: it is a convenience, not a progression.
269SUSPEND_MIGRATIONS: dict[int, Callable[[dict], dict]] = {}
270
271
272def _migrate(payload: dict, migrations: dict[int, Callable[[dict], dict]], current: int, label: str) -> dict:
273    """Walk *payload* up the migration chain until it reaches *current*."""
274    version = payload.get("schema_version")
275    if not isinstance(version, int):
276        raise SaveSchemaError(f"{label} has no integer 'schema_version' (got {version!r})")
277    if version > current:
278        raise SaveSchemaError(
279            f"{label} is at schema version {version} but this build only understands up to "
280            f"{current}; it was written by a newer version of the game"
281        )
282    while version < current:
283        step = migrations.get(version)
284        if step is None:
285            raise SaveSchemaError(
286                f"{label} is at schema version {version} and no migration to {version + 1} is registered"
287            )
288        payload = step(payload)
289        if not isinstance(payload, dict):
290            raise SaveSchemaError(f"{label} migration from version {version} returned {type(payload).__name__}")
291        version += 1
292        payload["schema_version"] = version
293    return payload
294
295
296def _fill_defaults(payload: dict, template: dict) -> dict:
297    """Return *payload* with every key missing against *template* filled in.
298
299    Recurses into nested dictionaries whose template is itself non-empty, which
300    leaves open-keyed maps (``best_scores``, ``lure.fragments``, per-hull and
301    per-biome tallies) untouched. Unknown keys are kept: a profile written by a
302    newer build and read back by an older one loses nothing it was not asked
303    about.
304    """
305    result = dict(payload)
306    for key, default in template.items():
307        if key not in result:
308            result[key] = deepcopy(default)
309        elif isinstance(default, dict) and default and isinstance(result[key], dict):
310            result[key] = _fill_defaults(result[key], default)
311    return result
312
313
314def migrate_profile(payload: dict) -> dict:
315    """Bring a stored profile to :data:`PROFILE_SCHEMA_VERSION`."""
316    return _fill_defaults(_migrate(payload, PROFILE_MIGRATIONS, PROFILE_SCHEMA_VERSION, "Profile"), default_profile())
317
318
319def migrate_suspend(payload: dict) -> dict:
320    """Bring a stored suspend save to :data:`SUSPEND_SCHEMA_VERSION`."""
321    migrated = _migrate(payload, SUSPEND_MIGRATIONS, SUSPEND_SCHEMA_VERSION, "Suspend save")
322    return _fill_defaults(migrated, blank_suspend())
323
324
325# ============================================================================
326# Where the files live
327# ============================================================================
328
329
330def data_dir() -> Path:
331    """The directory the two save files live in.
332
333    ``SHRIKE_DATA_DIR`` wins outright when set, which is what tests and
334    portable installs use. Otherwise the XDG data directory applies, falling
335    back to ``~/.local/share`` when ``XDG_DATA_HOME`` is unset or, as the
336    specification requires, not absolute. Nothing is ever written into the game
337    directory.
338    """
339    override = os.environ.get("SHRIKE_DATA_DIR")
340    if override:
341        return Path(override).expanduser()
342    xdg = os.environ.get("XDG_DATA_HOME")
343    base = Path(xdg) if xdg and Path(xdg).is_absolute() else Path.home() / ".local" / "share"
344    return base / "simvx" / "shrike"
345
346
347# ============================================================================
348# The record node and the save system
349# ============================================================================
350
351#: The record node's name. It is part of the on-disk format: SaveManager stores
352#: node paths, so renaming this invalidates existing files.
353RECORD_NAME = "ShrikeSave"
354
355
356class SaveRecord(Node):
357    """Carrier for one save payload.
358
359    A detached, single-node tree whose only persisted Property is the payload
360    dictionary. Keeping it out of the scene means its stored path is always
361    ``/ShrikeSave``, independent of where the :class:`SaveSystem` singleton
362    happens to sit.
363    """
364
365    __save_version__ = 1
366
367    payload = Property(default_factory=dict, persist=True, save_version=1)
368
369
370class SaveSystem(Node):
371    """The game's only door to disk, added as the ``Services.SAVE`` singleton.
372
373    Both slots round-trip plain dictionaries. The profile is the permanent
374    progression; the suspend save is a single mid-run snapshot that
375    :meth:`take_suspend` consumes.
376    """
377
378    profile_loaded = Signal(dict)  # SignalNames.PROFILE_LOADED
379    profile_saved = Signal()  # SignalNames.PROFILE_SAVED
380
381    # -- locations --------------------------------------------------------
382
383    def data_dir(self) -> Path:
384        """The save directory, resolved fresh so an env change is honoured."""
385        return data_dir()
386
387    def profile_path(self) -> Path:
388        """Where the meta profile is written."""
389        return self.data_dir() / f"{PROFILE_SLOT}.sav"
390
391    def suspend_path(self) -> Path:
392        """Where the suspend save is written."""
393        return self.data_dir() / f"{SUSPEND_SLOT}.sav"
394
395    def _manager(self) -> SaveManager:
396        return SaveManager(self.data_dir())
397
398    # -- the meta profile -------------------------------------------------
399
400    def load_profile(self) -> dict:
401        """Read the profile, migrating it forward; a fresh one if none exists.
402
403        Emits ``PROFILE_LOADED`` with the profile either way, so the first boot
404        and every later one take the same path through meta.py.
405        """
406        path = self.profile_path()
407        if not path.exists():
408            profile = default_profile()
409            self.profile_loaded.emit(profile)
410            return profile
411
412        try:
413            payload = self._read(PROFILE_SLOT)
414        except SaveError:
415            raise
416        except Exception as exc:
417            raise ProfileLoadError(
418                f"Could not read the profile at {path}: {exc}. The previous two writes are "
419                f"beside it as {path.name}.bak and {path.name}.bak2; rename one over it to recover."
420            ) from exc
421
422        profile = migrate_profile(payload)
423        self.profile_loaded.emit(profile)
424        return profile
425
426    def save_profile(self, profile: dict) -> None:
427        """Write *profile* atomically and emit ``PROFILE_SAVED``.
428
429        The payload is deep-copied, so the caller may keep mutating its own
430        dictionary without disturbing what landed on disk.
431        """
432        if not isinstance(profile, dict):
433            raise TypeError(f"save_profile expects a dict, got {type(profile).__name__}")
434        payload = deepcopy(profile)
435        payload["schema_version"] = PROFILE_SCHEMA_VERSION
436        self._write(PROFILE_SLOT, payload)
437        self.profile_saved.emit()
438
439    # -- the suspend slot -------------------------------------------------
440
441    def has_suspend(self) -> bool:
442        """Whether a suspended run is waiting to be resumed."""
443        return self.suspend_path().exists()
444
445    def write_suspend(self, run_state: dict) -> None:
446        """Capture a mid-sector run, replacing whatever the one slot held.
447
448        Every section in :data:`SUSPEND_SECTIONS` must be present and a
449        dictionary; keys missing inside a section are filled from
450        :func:`blank_suspend`. A half-described run is a resume that silently
451        loses the player's fuel or their vault progress, so it raises here
452        instead.
453        """
454        if not isinstance(run_state, dict):
455            raise TypeError(f"write_suspend expects a dict, got {type(run_state).__name__}")
456        missing = [name for name in SUSPEND_SECTIONS if not isinstance(run_state.get(name), dict)]
457        if missing:
458            raise ValueError(f"Run state is missing the section(s) {', '.join(missing)}; start from blank_suspend()")
459
460        payload = _fill_defaults(deepcopy(run_state), blank_suspend())
461        payload["schema_version"] = SUSPEND_SCHEMA_VERSION
462        payload["saved_at"] = datetime.now(UTC).isoformat()
463        self._write(SUSPEND_SLOT, payload)
464
465    def take_suspend(self) -> dict | None:
466        """Consume the suspended run, or ``None`` when there is none.
467
468        The slot and its rotated backups are deleted before the state is
469        returned, so a resumed run can never be replayed from the same save.
470        A slot that cannot be read is discarded the same way: it is a
471        convenience, and refusing to boot over it would be a worse trade than
472        losing it.
473        """
474        if not self.suspend_path().exists():
475            return None
476        try:
477            payload = self._read(SUSPEND_SLOT)
478            run_state = migrate_suspend(payload)
479        except Exception as exc:
480            log.warning("Discarding unreadable suspend save at %s: %s", self.suspend_path(), exc)
481            self.clear_suspend()
482            return None
483        self.clear_suspend()
484        return run_state
485
486    def clear_suspend(self) -> None:
487        """Delete the suspend slot and both rotated backups."""
488        path = self.suspend_path()
489        for candidate in (path, path.with_name(path.name + ".bak"), path.with_name(path.name + ".bak2")):
490            try:
491                candidate.unlink()
492            except FileNotFoundError:
493                pass
494
495    # -- SaveManager plumbing ---------------------------------------------
496
497    def _write(self, slot: str, payload: dict) -> Path:
498        record = SaveRecord(name=RECORD_NAME)
499        record.payload = payload
500        return self._manager().save(record, slot)
501
502    def _read(self, slot: str) -> dict:
503        record = SaveRecord(name=RECORD_NAME)
504        self._manager().apply(record, self._manager().load(slot))
505        payload = record.payload
506        if not isinstance(payload, dict):
507            raise SaveError(f"Slot {slot!r} holds a {type(payload).__name__}, expected a dict")
508        return payload
509
510
511def stored_settings() -> dict:
512    """The profile's settings block, read before there is a tree to read it in.
513
514    One caller: the window options the App is constructed with cannot wait for
515    the run to load the profile through the tree, because they are fixed before
516    the loop starts. Nothing is written here and nothing is migrated beyond
517    what :meth:`SaveSystem.load_profile` already does.
518
519    A profile that will not load answers with the defaults rather than raising.
520    The load ``flow.py`` runs a moment later hits the same file and reports it
521    properly, with the screen up to say it on; failing here would replace that
522    message with a traceback and no window.
523    """
524    try:
525        settings = SaveSystem().load_profile().get("settings")
526    except Exception:
527        return {}
528    return dict(settings) if isinstance(settings, dict) else {}