1use std::collections::HashMap;
22use std::marker::PhantomData;
23
24use bevy_asset::{
25 Asset, AssetEvent, AssetId, AssetLoader, Assets, Handle, LoadContext, io::Reader,
26};
27use bevy_ecs::component::Component;
28use bevy_ecs::event::Event;
29use bevy_ecs::message::MessageReader;
30use bevy_ecs::observer::On;
31use bevy_ecs::query::Without;
32use bevy_ecs::resource::Resource;
33use bevy_ecs::system::{Commands, Query, Res, ResMut};
34use bevy_ecs::world::World;
35use bevy_log::warn;
36use bevy_reflect::TypePath;
37use brink_format::LocaleData;
38use brink_runtime::{LocaleMode, RuntimeError, apply_locale};
39
40use crate::asset::{BrinkProgram, LineTablesAsset, ProgramAsset};
41use crate::line_tables::BrinkLocale;
42
43#[derive(Asset, TypePath)]
48pub struct LocaleAsset {
49 pub data: LocaleData,
50}
51
52#[derive(Default, TypePath)]
55pub struct InklLoader;
56
57#[derive(Debug, thiserror::Error)]
59pub enum InklLoaderError {
60 #[error("I/O error: {0}")]
61 Io(#[from] std::io::Error),
62 #[error("invalid .inkl: {0:?}")]
63 Decode(brink_format::DecodeError),
64}
65
66impl From<brink_format::DecodeError> for InklLoaderError {
67 fn from(err: brink_format::DecodeError) -> Self {
68 Self::Decode(err)
69 }
70}
71
72impl AssetLoader for InklLoader {
73 type Asset = LocaleAsset;
74 type Settings = ();
75 type Error = InklLoaderError;
76
77 async fn load(
78 &self,
79 reader: &mut dyn Reader,
80 _settings: &Self::Settings,
81 _load_context: &mut LoadContext<'_>,
82 ) -> Result<Self::Asset, Self::Error> {
83 let mut bytes = Vec::new();
84 reader.read_to_end(&mut bytes).await?;
85 let data = brink_format::read_inkl(&bytes)?;
86 Ok(LocaleAsset { data })
87 }
88
89 fn extensions(&self) -> &[&str] {
90 &["inkl"]
91 }
92}
93
94pub fn apply_locale_overlay(
108 program: &ProgramAsset,
109 base: &LineTablesAsset,
110 locale: &LocaleAsset,
111 mode: LocaleMode,
112 line_tables: &mut Assets<LineTablesAsset>,
113) -> Result<Handle<LineTablesAsset>, RuntimeError> {
114 let tables = apply_locale(&program.program, &locale.data, &base.tables, mode)?;
115 Ok(line_tables.add(LineTablesAsset { tables }))
116}
117
118#[derive(Resource)]
127pub struct BrinkCurrentLocale<M: Send + Sync + 'static = ()> {
128 pub locale: Option<Handle<LocaleAsset>>,
129 _marker: PhantomData<fn() -> M>,
130}
131
132impl<M: Send + Sync + 'static> Default for BrinkCurrentLocale<M> {
133 fn default() -> Self {
134 Self {
135 locale: None,
136 _marker: PhantomData,
137 }
138 }
139}
140
141#[derive(Event)]
143pub struct BrinkLocaleChanged<M: Send + Sync + 'static = ()> {
144 _marker: PhantomData<fn() -> M>,
145}
146
147impl<M: Send + Sync + 'static> Default for BrinkLocaleChanged<M> {
148 fn default() -> Self {
149 Self {
150 _marker: PhantomData,
151 }
152 }
153}
154
155#[derive(Component)]
161pub struct BrinkBaseLocale<M: Send + Sync + 'static = ()> {
162 pub handle: Handle<LineTablesAsset>,
163 _marker: PhantomData<fn() -> M>,
164}
165
166impl<M: Send + Sync + 'static> BrinkBaseLocale<M> {
167 #[must_use]
168 pub fn new(handle: Handle<LineTablesAsset>) -> Self {
169 Self {
170 handle,
171 _marker: PhantomData,
172 }
173 }
174}
175
176#[derive(Component, Default)]
180pub struct BrinkLocaleOverride<M: Send + Sync + 'static = ()> {
181 _marker: PhantomData<fn() -> M>,
182}
183
184#[derive(Resource)]
187pub struct LocalizedTablesCache<M: Send + Sync + 'static = ()> {
188 map: HashMap<(AssetId<LineTablesAsset>, AssetId<LocaleAsset>), Handle<LineTablesAsset>>,
189 _marker: PhantomData<fn() -> M>,
190}
191
192impl<M: Send + Sync + 'static> Default for LocalizedTablesCache<M> {
193 fn default() -> Self {
194 Self {
195 map: HashMap::new(),
196 _marker: PhantomData,
197 }
198 }
199}
200
201pub trait SetBrinkLocale {
203 fn set_brink_locale<M: Send + Sync + 'static>(&mut self, locale: Option<Handle<LocaleAsset>>);
206}
207
208impl SetBrinkLocale for Commands<'_, '_> {
209 fn set_brink_locale<M: Send + Sync + 'static>(&mut self, locale: Option<Handle<LocaleAsset>>) {
210 self.queue(move |world: &mut World| {
211 world
212 .get_resource_or_insert_with(BrinkCurrentLocale::<M>::default)
213 .locale = locale;
214 world.trigger(BrinkLocaleChanged::<M>::default());
215 });
216 }
217}
218
219fn reconcile_flow_locale<M: Send + Sync + 'static>(
227 base_handle: &Handle<LineTablesAsset>,
228 program: &ProgramAsset,
229 current: Option<&Handle<LocaleAsset>>,
230 locales: &Assets<LocaleAsset>,
231 cache: &mut LocalizedTablesCache<M>,
232 line_tables: &mut Assets<LineTablesAsset>,
233) -> Option<Handle<LineTablesAsset>> {
234 let Some(locale_handle) = current else {
235 return Some(base_handle.clone());
236 };
237
238 let key = (base_handle.id(), locale_handle.id());
239 if let Some(handle) = cache.map.get(&key) {
240 return Some(handle.clone());
241 }
242
243 let locale = locales.get(locale_handle)?;
245 let base = line_tables.get(base_handle)?;
246 let result = apply_locale(
247 &program.program,
248 &locale.data,
249 &base.tables,
250 LocaleMode::Overlay,
251 );
252 match result {
253 Ok(tables) => {
254 let handle = line_tables.add(LineTablesAsset { tables });
255 cache.map.insert(key, handle.clone());
256 Some(handle)
257 }
258 Err(err) => {
259 warn!("brink: locale overlay failed to apply ({err}); staying on base");
260 Some(base_handle.clone())
261 }
262 }
263}
264
265#[expect(
266 clippy::type_complexity,
267 reason = "bevy query tuple for flow locale reconcile"
268)]
269fn reconcile_all_flows<M: Send + Sync + 'static>(
270 current: &BrinkCurrentLocale<M>,
271 programs: &Assets<ProgramAsset>,
272 locales: &Assets<LocaleAsset>,
273 line_tables: &mut Assets<LineTablesAsset>,
274 cache: &mut LocalizedTablesCache<M>,
275 flows: &mut Query<
276 (&BrinkProgram<M>, &BrinkBaseLocale<M>, &mut BrinkLocale<M>),
277 Without<BrinkLocaleOverride<M>>,
278 >,
279) {
280 for (prog, base, mut active) in flows.iter_mut() {
281 let Some(program) = programs.get(&prog.handle) else {
282 continue;
283 };
284 if let Some(handle) = reconcile_flow_locale(
285 &base.handle,
286 program,
287 current.locale.as_ref(),
288 locales,
289 cache,
290 line_tables,
291 ) {
292 active.handle = handle;
293 }
294 }
295}
296
297#[expect(
300 clippy::needless_pass_by_value,
301 clippy::type_complexity,
302 reason = "bevy systems take params by value and have complex query tuples"
303)]
304pub fn on_locale_changed<M: Send + Sync + 'static>(
305 _on: On<BrinkLocaleChanged<M>>,
306 current: Res<BrinkCurrentLocale<M>>,
307 programs: Res<Assets<ProgramAsset>>,
308 locales: Res<Assets<LocaleAsset>>,
309 mut line_tables: ResMut<Assets<LineTablesAsset>>,
310 mut cache: ResMut<LocalizedTablesCache<M>>,
311 mut flows: Query<
312 (&BrinkProgram<M>, &BrinkBaseLocale<M>, &mut BrinkLocale<M>),
313 Without<BrinkLocaleOverride<M>>,
314 >,
315) {
316 reconcile_all_flows(
317 ¤t,
318 &programs,
319 &locales,
320 &mut line_tables,
321 &mut cache,
322 &mut flows,
323 );
324}
325
326#[expect(
330 clippy::needless_pass_by_value,
331 clippy::type_complexity,
332 reason = "bevy systems take params by value and have complex query tuples"
333)]
334pub fn catch_up_loaded_locales<M: Send + Sync + 'static>(
335 mut events: MessageReader<AssetEvent<LocaleAsset>>,
336 current: Res<BrinkCurrentLocale<M>>,
337 programs: Res<Assets<ProgramAsset>>,
338 locales: Res<Assets<LocaleAsset>>,
339 mut line_tables: ResMut<Assets<LineTablesAsset>>,
340 mut cache: ResMut<LocalizedTablesCache<M>>,
341 mut flows: Query<
342 (&BrinkProgram<M>, &BrinkBaseLocale<M>, &mut BrinkLocale<M>),
343 Without<BrinkLocaleOverride<M>>,
344 >,
345) {
346 let current_id = current.locale.as_ref().map(Handle::id);
347 let relevant = events.read().any(|ev| match ev {
349 AssetEvent::Added { id }
350 | AssetEvent::Modified { id }
351 | AssetEvent::LoadedWithDependencies { id } => Some(*id) == current_id,
352 _ => false,
353 });
354 if !relevant {
355 return;
356 }
357 reconcile_all_flows(
358 ¤t,
359 &programs,
360 &locales,
361 &mut line_tables,
362 &mut cache,
363 &mut flows,
364 );
365}
366
367pub(crate) fn initial_locale_handle<M: Send + Sync + 'static>(
372 base_handle: &Handle<LineTablesAsset>,
373 program: &ProgramAsset,
374 current: Option<&BrinkCurrentLocale<M>>,
375 locales: &Assets<LocaleAsset>,
376 cache: &mut LocalizedTablesCache<M>,
377 line_tables: &mut Assets<LineTablesAsset>,
378) -> Handle<LineTablesAsset> {
379 let current_locale = current.and_then(|c| c.locale.as_ref());
380 reconcile_flow_locale(
381 base_handle,
382 program,
383 current_locale,
384 locales,
385 cache,
386 line_tables,
387 )
388 .unwrap_or_else(|| base_handle.clone())
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::BrinkFlowRequest;
395 use crate::asset::BrinkStoryAsset;
396 use crate::test_support::make_test_app;
397 use bevy_app::App;
398 use bevy_ecs::entity::Entity;
399 use brink_format::LineContent;
400 use brink_intl::ContentJson;
401 use brink_runtime::FlowInstance;
402
403 fn setup(
408 base_src: &str,
409 translation: &str,
410 ) -> (App, Handle<BrinkStoryAsset>, Handle<LocaleAsset>) {
411 let owned = base_src.to_string();
412 let out = brink_compiler::compile("t.ink", move |p| {
413 if p == "t.ink" {
414 Ok(owned.clone())
415 } else {
416 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
417 }
418 })
419 .expect("compile");
420 let data = out.data;
421
422 let mut inkb = Vec::new();
423 brink_format::write_inkb(&data, &mut inkb);
424 let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
425 let (program, base_tables) = brink_runtime::link(&loaded).expect("link");
426 let checksum = brink_format::read_inkb_index(&inkb)
427 .expect("index")
428 .checksum;
429
430 let mut lines = brink_intl::export_lines(&loaded, checksum);
431 lines.scopes[0].lines[0].content = Some(ContentJson::Plain(translation.to_string()));
432 let inkl_bytes = brink_intl::compile_locale(&inkb, &lines, "es").expect("compile_locale");
433 let locale_data = brink_format::read_inkl(&inkl_bytes).expect("read_inkl");
434
435 let mut app = make_test_app();
436 let (_, initial_context) = FlowInstance::new_at_root(&program);
437 let world = app.world_mut();
438 let program_h = world
439 .resource_mut::<Assets<ProgramAsset>>()
440 .add(ProgramAsset {
441 program,
442 initial_context,
443 effect_rows: loaded.effect_rows,
444 });
445 let base_h = world
446 .resource_mut::<Assets<LineTablesAsset>>()
447 .add(LineTablesAsset {
448 tables: base_tables,
449 });
450 let story_h = world
451 .resource_mut::<Assets<BrinkStoryAsset>>()
452 .add(BrinkStoryAsset {
453 program: program_h,
454 line_tables: base_h,
455 });
456 let locale_h = world
457 .resource_mut::<Assets<LocaleAsset>>()
458 .add(LocaleAsset { data: locale_data });
459 (app, story_h, locale_h)
460 }
461
462 fn active_text_contains(app: &App, flow: Entity, needle: &str) -> bool {
465 let handle = app
466 .world()
467 .entity(flow)
468 .get::<BrinkLocale<()>>()
469 .expect("BrinkLocale")
470 .handle
471 .clone();
472 let tables = &app
473 .world()
474 .resource::<Assets<LineTablesAsset>>()
475 .get(&handle)
476 .expect("active line tables")
477 .tables;
478 tables.iter().flatten().any(|e| match &e.content {
479 LineContent::Plain(s) => s.contains(needle),
480 LineContent::Template(_) => false,
481 })
482 }
483
484 fn switch_to(app: &mut App, locale: Option<Handle<LocaleAsset>>) {
485 app.world_mut()
486 .resource_mut::<BrinkCurrentLocale<()>>()
487 .locale = locale;
488 app.world_mut().trigger(BrinkLocaleChanged::<()>::default());
489 app.update();
490 }
491
492 #[test]
493 fn global_switch_localizes_and_reverts() {
494 let (mut app, story, locale) = setup("Hello world\n-> END\n", "[ES] Hola mundo\n");
495 let flow = app
496 .world_mut()
497 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
498 .id();
499 app.update(); assert!(
502 active_text_contains(&app, flow, "Hello world"),
503 "starts on base"
504 );
505 assert!(!active_text_contains(&app, flow, "[ES]"));
506
507 switch_to(&mut app, Some(locale));
508 assert!(
509 active_text_contains(&app, flow, "[ES] Hola mundo"),
510 "switched to es"
511 );
512
513 switch_to(&mut app, None);
514 assert!(
515 active_text_contains(&app, flow, "Hello world"),
516 "reverted to base"
517 );
518 assert!(!active_text_contains(&app, flow, "[ES]"));
519 }
520
521 #[test]
522 fn override_flow_is_not_switched() {
523 let (mut app, story, locale) = setup("Hello world\n-> END\n", "[ES] Hola mundo\n");
524 let flow = app
525 .world_mut()
526 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
527 .id();
528 app.update();
529 app.world_mut()
530 .entity_mut(flow)
531 .insert(BrinkLocaleOverride::<()>::default());
532
533 switch_to(&mut app, Some(locale));
534 assert!(
535 active_text_contains(&app, flow, "Hello world"),
536 "override flow stays on base"
537 );
538 assert!(!active_text_contains(&app, flow, "[ES]"));
539 }
540
541 #[test]
542 fn flow_spawned_while_locale_set_starts_localized() {
543 let (mut app, story, locale) = setup("Hello world\n-> END\n", "[ES] Hola mundo\n");
544 app.world_mut()
546 .resource_mut::<BrinkCurrentLocale<()>>()
547 .locale = Some(locale);
548 let flow = app
549 .world_mut()
550 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
551 .id();
552 app.update(); assert!(
555 active_text_contains(&app, flow, "[ES] Hola mundo"),
556 "new flow starts localized"
557 );
558 }
559
560 #[test]
561 fn flows_share_cached_localized_tables() {
562 let (mut app, story, locale) = setup("Hello world\n-> END\n", "[ES] Hola mundo\n");
563 let a = app
564 .world_mut()
565 .spawn(
566 BrinkFlowRequest::<()>::builder()
567 .story(story.clone())
568 .build(),
569 )
570 .id();
571 let b = app
572 .world_mut()
573 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
574 .id();
575 app.update();
576
577 switch_to(&mut app, Some(locale));
578 let ha = app
579 .world()
580 .entity(a)
581 .get::<BrinkLocale<()>>()
582 .expect("a")
583 .handle
584 .clone();
585 let hb = app
586 .world()
587 .entity(b)
588 .get::<BrinkLocale<()>>()
589 .expect("b")
590 .handle
591 .clone();
592 assert_eq!(
593 ha, hb,
594 "both flows share one cached localized LineTablesAsset"
595 );
596 }
597}