Skip to main content

brink_runtime/
save.rs

1//! Story `save_state` / `load_state`: produce and reconcile the durable,
2//! name-keyed [`SaveState`] game-state save.
3//!
4//! [`SaveState`] (defined in `brink-format`) is distinct from the in-memory
5//! [`StorySnapshot`](crate::StorySnapshot), which captures full execution
6//! position and is locked to one exact program build. `SaveState` captures
7//! only *game state* — globals, visit/turn counts, turn index, RNG — keyed by
8//! stable identities (variable name; scope `DefinitionId`), so a save survives
9//! a story recompile/patch as long as the relevant names/paths are unchanged.
10//! Execution position is deliberately not captured; the host re-enters a
11//! conversation at a known knot. See `docs/external-binding-foundation.md`.
12
13use brink_format::{DefinitionId, LoadReport, SAVE_FORMAT_VERSION, SaveState, VisitEntry};
14
15use crate::StoryRng;
16use crate::debug::NameResolver;
17use crate::story::Story;
18
19impl<R: StoryRng> Story<'_, R> {
20    /// Capture the default flow's game state as a durable, name-keyed
21    /// [`SaveState`]. Does not capture execution position.
22    #[must_use]
23    pub fn save_state(&self) -> SaveState {
24        let ctx = &self.default_context;
25        let resolver = NameResolver::new(self.program());
26
27        let globals = ctx
28            .globals
29            .iter()
30            .enumerate()
31            .filter_map(|(i, v)| {
32                self.program()
33                    .global_slot_name(i)
34                    .map(|name| (name.to_owned(), v.clone()))
35            })
36            .collect();
37
38        let to_entries = |map: &std::collections::HashMap<DefinitionId, u32>| {
39            let mut entries: Vec<VisitEntry> = map
40                .iter()
41                .map(|(&id, &count)| VisitEntry {
42                    id,
43                    path: resolver.def_path(id).map(str::to_owned),
44                    count,
45                })
46                .collect();
47            entries.sort_by_key(|e| e.id.to_raw());
48            entries
49        };
50
51        SaveState {
52            version: SAVE_FORMAT_VERSION,
53            globals,
54            visits: to_entries(&ctx.visit_counts),
55            turns: to_entries(&ctx.turn_counts),
56            turn_index: ctx.turn_index,
57            rng_seed: ctx.rng_seed,
58            previous_random: ctx.previous_random,
59        }
60    }
61
62    /// Reconcile a [`SaveState`] into the default flow's context, returning a
63    /// [`LoadReport`] of anything that couldn't be applied. Globals are matched
64    /// by name; visit/turn counts by id. Tolerant of story patches: unknown
65    /// globals are reported, scopes the program no longer has retain their
66    /// saved counts harmlessly.
67    pub fn load_state(&mut self, save: &SaveState) -> LoadReport {
68        let mut report = LoadReport::default();
69
70        for (name, value) in &save.globals {
71            match self.program().global_index(name) {
72                Some(idx) => self.default_context.set_global(idx, value.clone()),
73                None => report.unknown_globals.push(name.clone()),
74            }
75        }
76
77        let ctx = &mut self.default_context;
78        ctx.turn_index = save.turn_index;
79        ctx.rng_seed = save.rng_seed;
80        ctx.previous_random = save.previous_random;
81        for e in &save.visits {
82            ctx.visit_counts.insert(e.id, e.count);
83        }
84        for e in &save.turns {
85            ctx.turn_counts.insert(e.id, e.count);
86        }
87
88        report
89    }
90}