Skip to main content

bevy_brink/
brkt.rs

1//! `.brkt` transcript persistence.
2//!
3//! A `.brkt` is the serialized output history of a playthrough — the
4//! append-only log of structural output parts (line refs, values, glue,
5//! tags). Because it stores *structure*, not resolved strings, a saved
6//! transcript can be re-rendered against any matching program + locale
7//! without re-running the story. Uses: a story-log game mechanic, QA
8//! capture, and the visible-history half of a save file.
9//!
10//! This is a thin bevy layer over the runtime's
11//! [`brink_runtime::transcript`] serialization:
12//!
13//! - [`capture_transcript`] — a live flow's transcript → `.brkt` bytes (write
14//!   them into your save file).
15//! - [`TranscriptAsset`] + [`BrktLoader`] — load saved `.brkt` bytes as an
16//!   asset.
17//! - [`render_transcript_asset`] — a loaded transcript + program + locale →
18//!   rendered `(text, tags)` lines (validates the program checksum first).
19
20use bevy_asset::{Asset, AssetLoader, LoadContext, io::Reader};
21use bevy_reflect::TypePath;
22use brink_format::PluralResolver;
23use brink_runtime::transcript::{
24    TranscriptData, TranscriptError, read_transcript, render_transcript, write_transcript,
25};
26
27use crate::asset::{LineTablesAsset, ProgramAsset};
28use crate::flow::BrinkFlow;
29
30/// A loaded `.brkt` transcript — the output history of a (past) playthrough,
31/// re-renderable against any matching program + locale.
32#[derive(Asset, TypePath)]
33pub struct TranscriptAsset {
34    pub data: TranscriptData,
35}
36
37/// Asset loader for `.brkt` (serialized transcript) files. Decodes via
38/// [`brink_runtime::transcript::read_transcript`].
39#[derive(Default, TypePath)]
40pub struct BrktLoader;
41
42/// Errors that can occur loading a `.brkt` file.
43#[derive(Debug, thiserror::Error)]
44pub enum BrktLoaderError {
45    #[error("I/O error: {0}")]
46    Io(#[from] std::io::Error),
47    #[error("invalid .brkt: {0}")]
48    Decode(#[from] TranscriptError),
49}
50
51impl AssetLoader for BrktLoader {
52    type Asset = TranscriptAsset;
53    type Settings = ();
54    type Error = BrktLoaderError;
55
56    async fn load(
57        &self,
58        reader: &mut dyn Reader,
59        _settings: &Self::Settings,
60        _load_context: &mut LoadContext<'_>,
61    ) -> Result<Self::Asset, Self::Error> {
62        let mut bytes = Vec::new();
63        reader.read_to_end(&mut bytes).await?;
64        let data = read_transcript(&bytes)?;
65        Ok(TranscriptAsset { data })
66    }
67
68    fn extensions(&self) -> &[&str] {
69        &["brkt"]
70    }
71}
72
73/// Serialize a flow's current transcript to `.brkt` bytes for saving.
74///
75/// Store the returned bytes however your save system persists them; load
76/// them back through the `.brkt` asset loader (or
77/// [`read_transcript`](brink_runtime::transcript::read_transcript)) and
78/// re-display with [`render_transcript_asset`]. The bytes embed the program's
79/// `source_checksum` so a load can detect a mismatched story version.
80#[must_use]
81pub fn capture_transcript<M: Send + Sync + 'static>(
82    flow: &BrinkFlow<M>,
83    program: &ProgramAsset,
84) -> Vec<u8> {
85    write_transcript(
86        flow.inner.transcript(),
87        program.program.source_checksum(),
88        flow.inner.fragments(),
89    )
90}
91
92/// Re-render a loaded transcript against a program + locale line tables,
93/// producing `(text, tags)` per line — the same output the live
94/// [`BrinkTranscript`](crate::BrinkTranscript) would show.
95///
96/// Validates the transcript's `source_checksum` against the program first
97/// (rendering against the wrong story would produce garbage), so pass the
98/// program the transcript was captured from. `line_tables` may be the base
99/// or any localized tables — the saved history re-renders in that locale.
100///
101/// # Errors
102/// [`TranscriptError::ChecksumMismatch`] if the transcript wasn't produced by
103/// this program.
104pub fn render_transcript_asset(
105    transcript: &TranscriptAsset,
106    program: &ProgramAsset,
107    line_tables: &LineTablesAsset,
108    resolver: Option<&dyn PluralResolver>,
109) -> Result<Vec<(String, Vec<String>)>, TranscriptError> {
110    let program_checksum = program.program.source_checksum();
111    if transcript.data.source_checksum != program_checksum {
112        return Err(TranscriptError::ChecksumMismatch {
113            transcript: transcript.data.source_checksum,
114            program: program_checksum,
115        });
116    }
117    Ok(render_transcript(
118        &transcript.data.parts,
119        &program.program,
120        &line_tables.tables,
121        resolver,
122        &transcript.data.fragments,
123    ))
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use brink_runtime::{FallbackHandler, FastRng, FlowInstance, LocaleMode, apply_locale};
130
131    /// Compile a story, round-trip it through `.inkb` (so the program carries a
132    /// real checksum), drive a root flow to the end, and return the driven
133    /// flow + its program asset + base line tables.
134    fn driven(src: &str) -> (BrinkFlow<()>, ProgramAsset, LineTablesAsset) {
135        let owned = src.to_string();
136        let out = brink_compiler::compile("t.ink", move |p| {
137            if p == "t.ink" {
138                Ok(owned.clone())
139            } else {
140                Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
141            }
142        })
143        .expect("compile");
144        let mut inkb = Vec::new();
145        brink_format::write_inkb(&out.data, &mut inkb);
146        let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
147        let (program, tables) = brink_runtime::link(&loaded).expect("link");
148
149        let (mut flow, mut ctx) = FlowInstance::new_at_root(&program);
150        for _ in 0..10_000 {
151            let line = flow
152                .step_single_line::<FastRng>(&program, &tables, &mut ctx, &FallbackHandler, None)
153                .expect("step");
154            if line.is_terminal() {
155                break;
156            }
157        }
158        let (_, initial_context) = FlowInstance::new_at_root(&program);
159        (
160            BrinkFlow::<()>::new(flow),
161            ProgramAsset {
162                program,
163                initial_context,
164                effect_rows: loaded.effect_rows,
165            },
166            LineTablesAsset { tables },
167        )
168    }
169
170    #[test]
171    fn capture_roundtrips_and_renders_like_live() {
172        let (flow, prog, base) = driven("Hello there.\nGeneral Kenobi.\n-> END\n");
173
174        let bytes = capture_transcript::<()>(&flow, &prog);
175        let reloaded = TranscriptAsset {
176            data: read_transcript(&bytes).expect("read_transcript"),
177        };
178
179        let rendered = render_transcript_asset(&reloaded, &prog, &base, None).expect("render");
180        let text = rendered
181            .iter()
182            .map(|(t, _)| t.as_str())
183            .collect::<Vec<_>>()
184            .join("\n");
185        assert!(text.contains("Hello there."), "got {text:?}");
186        assert!(text.contains("General Kenobi."), "got {text:?}");
187
188        // A reloaded-then-rendered transcript matches rendering the live one.
189        let live = render_transcript(
190            flow.inner.transcript(),
191            &prog.program,
192            &base.tables,
193            None,
194            flow.inner.fragments(),
195        );
196        assert_eq!(rendered, live);
197    }
198
199    #[test]
200    fn render_rejects_mismatched_program() {
201        let (flow, prog, base) = driven("A line.\n-> END\n");
202        let bytes = capture_transcript::<()>(&flow, &prog);
203        let reloaded = TranscriptAsset {
204            data: read_transcript(&bytes).expect("read"),
205        };
206
207        // A different story → different checksum → mismatch error.
208        let (_, other_prog, _) = driven("An entirely different tale.\n-> END\n");
209        let err = render_transcript_asset(&reloaded, &other_prog, &base, None).unwrap_err();
210        assert!(
211            matches!(err, TranscriptError::ChecksumMismatch { .. }),
212            "got {err:?}"
213        );
214    }
215
216    #[test]
217    fn saved_transcript_renders_in_a_different_locale() {
218        // Capture on the base locale, then re-render the SAME saved transcript
219        // against a localized line table — the persisted history localizes.
220        let src = "Hello world\n-> END\n";
221        let (flow, prog, base) = driven(src);
222        let bytes = capture_transcript::<()>(&flow, &prog);
223        let reloaded = TranscriptAsset {
224            data: read_transcript(&bytes).expect("read"),
225        };
226
227        // Build an `es` overlay translating the first line.
228        let out = brink_compiler::compile("t.ink", |p| {
229            if p == "t.ink" {
230                Ok(src.to_string())
231            } else {
232                Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
233            }
234        })
235        .expect("compile");
236        let mut inkb = Vec::new();
237        brink_format::write_inkb(&out.data, &mut inkb);
238        let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
239        let checksum = brink_format::read_inkb_index(&inkb)
240            .expect("index")
241            .checksum;
242        let mut lines = brink_intl::export_lines(&loaded, checksum);
243        lines.scopes[0].lines[0].content =
244            Some(brink_intl::ContentJson::Plain("Hola mundo\n".to_string()));
245        let inkl_bytes = brink_intl::compile_locale(&inkb, &lines, "es").expect("compile_locale");
246        let locale_data = brink_format::read_inkl(&inkl_bytes).expect("read_inkl");
247        let es_tables = apply_locale(
248            &prog.program,
249            &locale_data,
250            &base.tables,
251            LocaleMode::Overlay,
252        )
253        .expect("apply_locale");
254
255        let rendered = render_transcript_asset(
256            &reloaded,
257            &prog,
258            &LineTablesAsset { tables: es_tables },
259            None,
260        )
261        .expect("render");
262        let text = rendered.iter().map(|(t, _)| t.as_str()).collect::<String>();
263        assert!(
264            text.contains("Hola mundo"),
265            "saved history localizes; got {text:?}"
266        );
267    }
268}