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}