Skip to main content

bevy_brink/
locale.rs

1//! `.inkl` locale overlays: loading, application, and global switching.
2//!
3//! A `.inkl` file is a translation overlay produced by `brink-intl`. This
4//! module loads it as a [`LocaleAsset`], applies it to a story's base line
5//! tables (via [`brink_runtime::apply_locale`]) to produce a localized
6//! [`LineTablesAsset`], and wires up **global, event-driven** locale
7//! switching:
8//!
9//! - [`BrinkCurrentLocale<M>`] — the resource holding the active locale
10//!   (`None` = base/source language).
11//! - [`SetBrinkLocale::set_brink_locale`] — the API: sets the resource and
12//!   fires [`BrinkLocaleChanged<M>`].
13//! - An observer on that event (plus a catch-up system for `.inkl`s that
14//!   finish loading after a switch, plus a spawn-time read in
15//!   `fulfill_flow_requests`) reconciles every flow's [`BrinkLocale`] to the
16//!   current locale. The transcript then re-renders via the existing
17//!   `Changed<BrinkLocale>` reactivity. No per-frame polling.
18//! - [`BrinkLocaleOverride<M>`] — a marker that opts a flow out of global
19//!   switching (its locale is set manually via [`apply_locale_overlay`]).
20
21use 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// ── Asset + loader ──────────────────────────────────────────────────────────
44
45/// A parsed `.inkl` locale overlay. Apply it to a story's base line tables
46/// with [`apply_locale_overlay`] (or let the global locale machinery do it).
47#[derive(Asset, TypePath)]
48pub struct LocaleAsset {
49    pub data: LocaleData,
50}
51
52/// Asset loader for `.inkl` (compiled locale overlay) files. Decodes via
53/// [`brink_format::read_inkl`] into a [`LocaleAsset`].
54#[derive(Default, TypePath)]
55pub struct InklLoader;
56
57/// Errors that can occur loading an `.inkl` file.
58#[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
94// ── Apply helper (primitive) ──────────────────────────────────────────────
95
96/// Apply a locale overlay to a story's base line tables, inserting the
97/// resulting localized [`LineTablesAsset`] and returning its handle.
98///
99/// The building block for locale switching: point a flow's
100/// [`BrinkLocale::handle`](crate::BrinkLocale) at the returned handle to
101/// render that flow in this locale (the transcript re-renders automatically).
102///
103/// # Errors
104/// Propagates [`apply_locale`] errors — notably
105/// [`LocaleChecksumMismatch`](RuntimeError::LocaleChecksumMismatch) when the
106/// overlay was built against a different `.inkb`.
107pub 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// ── Global, event-driven locale ─────────────────────────────────────────────
119
120/// The active locale for story marker `M`. `None` = base/source language.
121///
122/// The game's single source of truth for "what language are we in." Switch
123/// it with [`SetBrinkLocale::set_brink_locale`] (which also fires
124/// [`BrinkLocaleChanged`] so flows reconcile). The plugin inserts this
125/// (default `None`) automatically.
126#[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/// Fired when the global locale changes; reconciles all flows.
142#[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/// A flow's canonical **base** line tables (the `.inkb`'s `#line_tables`).
156///
157/// Inserted at fulfillment alongside [`BrinkLocale`]. Locale overlays always
158/// apply to this base, never to an already-localized table, and reverting to
159/// the base locale restores it.
160#[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/// Marker: a flow carrying this is **excluded** from global locale reconcile.
177/// Drive its [`BrinkLocale`] manually (e.g. a polyglot NPC) via
178/// [`apply_locale_overlay`].
179#[derive(Component, Default)]
180pub struct BrinkLocaleOverride<M: Send + Sync + 'static = ()> {
181    _marker: PhantomData<fn() -> M>,
182}
183
184/// Caches localized line tables per `(base, locale)` so all flows in a locale
185/// share one [`LineTablesAsset`] rather than rebuilding it per flow.
186#[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
201/// [`Commands`] extension to switch the global locale.
202pub trait SetBrinkLocale {
203    /// Set the active locale for marker `M` (`None` = base) and fire
204    /// [`BrinkLocaleChanged<M>`] so all non-override flows reconcile.
205    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
219/// Compute the [`LineTablesAsset`] handle a flow's [`BrinkLocale`] should
220/// point at for the current locale.
221///
222/// Returns `Some(base)` when no locale is active; the cached/built localized
223/// handle when the `.inkl` (and base) are loaded; or `None` (leave the flow's
224/// current handle unchanged — it will catch up once the `.inkl` loads) when
225/// the overlay isn't ready yet. Apply errors `warn!` and fall back to base.
226fn 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    // Need both the overlay and the base tables loaded to build.
244    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/// Observer (registered by the plugin) that reconciles every non-override
298/// flow's locale when [`BrinkLocaleChanged`] fires.
299#[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        &current,
318        &programs,
319        &locales,
320        &mut line_tables,
321        &mut cache,
322        &mut flows,
323    );
324}
325
326/// Plugin system: when the current locale's `.inkl` finishes loading (or is
327/// hot-reloaded) *after* a switch/spawn, reconcile so flows pick it up. Reads
328/// asset events; no-ops when nothing relevant loaded.
329#[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    // Always drain the reader; only reconcile if the *current* locale loaded.
348    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        &current,
359        &programs,
360        &locales,
361        &mut line_tables,
362        &mut cache,
363        &mut flows,
364    );
365}
366
367/// Reconcile a single newly-spawned flow's locale against the current locale,
368/// returning the handle its [`BrinkLocale`] should start at (base if no locale
369/// is active or the overlay isn't loaded yet — `catch_up_loaded_locales` will
370/// localize it once the `.inkl` loads). Used by `fulfill_flow_requests`.
371pub(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    /// Compile `base_src`, round-trip through `.inkb` (so the program carries
404    /// a real checksum), build an `es` overlay translating the first scope's
405    /// first line, and stand up an app with all four assets inserted.
406    /// Returns the app + the story and locale handles.
407    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    /// True if any line in the flow's *active* line tables (what `BrinkLocale`
463    /// currently points at) contains `needle`.
464    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(); // fulfill
500
501        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        // Set the locale BEFORE spawning any flow (overlay already loaded).
545        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(); // fulfill reads the current locale at spawn
553
554        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}