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 {}