1use 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#[derive(Asset, TypePath)]
33pub struct TranscriptAsset {
34 pub data: TranscriptData,
35}
36
37#[derive(Default, TypePath)]
40pub struct BrktLoader;
41
42#[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#[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
92pub 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 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 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 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 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 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}