Skip to main content

bevy_brink/
asset.rs

1//! Asset types and loaders for compiled brink stories.
2
3use std::marker::PhantomData;
4
5use bevy_asset::{Asset, AssetLoader, Handle, LoadContext, io::Reader};
6use bevy_ecs::bundle::Bundle;
7use bevy_ecs::component::Component;
8use bevy_reflect::TypePath;
9use brink_format::{EffectRowEntry, LineEntry};
10use brink_runtime::{FlowInstance, Program, RuntimeError, World};
11
12use crate::line_tables::BrinkLocale;
13
14/// The immutable bytecode portion of a compiled story — what the VM
15/// actually executes — together with the fresh starting [`World`](brink_runtime::World)
16/// (globals seeded from `VAR`/`CONST`/`LIST` defaults; zero visit and
17/// turn counts, all-`World` policy).
18///
19/// `initial_context` is read-only "fresh start" state, exposed for
20/// consumers that want to compare against or reset toward program
21/// defaults. **It is not what seeds [`BrinkGlobals`](crate::BrinkGlobals)**
22/// — since F6.2, `fulfill_flow_requests` creates the shared `BrinkGlobals`
23/// `World` via [`brink_runtime::World::new`], resolving the host's
24/// [`WorldPolicy`](brink_runtime::WorldPolicy) (installed via
25/// [`BrinkPlugin::with_policy`](crate::BrinkPlugin::with_policy)) against
26/// this program's symbol table — `initial_context` (always the all-`World`
27/// policy) plays no part in that. There is no "commit back to reset"
28/// verb either; see [`BrinkGlobals`](crate::BrinkGlobals)'s docs.
29///
30/// No execution happens to produce this — it's a pure function of the
31/// linked [`Program`]'s declarations. Stories with free-floating
32/// top-of-file setup (`~ initialize_save_data()` etc.) need a flow at
33/// root to advance through that code; the runtime doesn't pre-run it.
34///
35/// Produced as a labeled subasset by [`InkbLoader`] (and the `.ink`
36/// source loader) under the label `program`. Reference it through
37/// [`BrinkStoryAsset::program`] or load it directly via the labeled
38/// path `path.inkb#program`.
39#[derive(Asset, TypePath)]
40pub struct ProgramAsset {
41    pub program: Program,
42    pub initial_context: World,
43    /// The story's decoded `EffectRows` `DefinitionId → row` table (T2-3,
44    /// `docs/effects-spec.md` §11; PR #878) — carried here rather than on
45    /// [`Program`] itself because BH-1's capability join
46    /// (`crate::capability::compute_container_access`) needs a live
47    /// `CapabilityRegistry` resource (app `World` access) to resolve
48    /// capability names to `ComponentId`s, which an [`AssetLoader`] never
49    /// has; the join runs later, in a system reacting to this asset's load
50    /// event, so the rows must survive to that point. Empty for stories
51    /// compiled before T2-3 shipped rows, or that declare no knots/stitches.
52    pub effect_rows: Vec<EffectRowEntry>,
53}
54
55/// The localized line-table portion of a compiled story — the swappable
56/// rendering data.
57///
58/// Every `.inkb` carries its source-language line tables embedded; the
59/// loader splits them out as their own asset so future hot-reload
60/// machinery can update tables independently of the program. Additional
61/// `.inkl` overlays will load as standalone `LineTablesAsset`s when that
62/// loader lands.
63///
64/// Loaded as a labeled subasset under the label `line_tables` from
65/// [`InkbLoader`], or directly via `path.inkb#line_tables`.
66#[derive(Asset, TypePath)]
67pub struct LineTablesAsset {
68    pub tables: Vec<Vec<LineEntry>>,
69}
70
71/// Top-level "story" asset — a thin bundle pairing the two
72/// labeled subassets ([`ProgramAsset`], [`LineTablesAsset`]) that
73/// together describe a loaded story.
74///
75/// `.inkb` and `.ink` loaders emit this. Consumers usually don't need
76/// to load the labeled subassets directly — they spawn an entity with
77/// a [`BrinkFlowRequest`](crate::BrinkFlowRequest) carrying a
78/// `Handle<BrinkStoryAsset>` and let the fulfillment system wire
79/// everything up.
80#[derive(Asset, TypePath)]
81pub struct BrinkStoryAsset {
82    pub program: Handle<ProgramAsset>,
83    pub line_tables: Handle<LineTablesAsset>,
84}
85
86/// Asset loader for `.inkb` (compiled bytecode) files.
87///
88/// Reads the bytes, decodes via [`brink_format::read_inkb`], links via
89/// [`brink_runtime::link`], computes the fresh starting [`World`](brink_runtime::World)
90/// from the program's declarations, and emits labeled subassets
91/// (`#program`, `#line_tables`) bundled in the returned
92/// [`BrinkStoryAsset`].
93#[derive(Default, TypePath)]
94pub struct InkbLoader;
95
96/// Errors that can occur loading an `.inkb` file.
97#[derive(Debug, thiserror::Error)]
98pub enum InkbLoaderError {
99    #[error("I/O error: {0}")]
100    Io(#[from] std::io::Error),
101    #[error("invalid .inkb: {0:?}")]
102    Decode(brink_format::DecodeError),
103    #[error("link error: {0}")]
104    Link(#[from] RuntimeError),
105}
106
107impl From<brink_format::DecodeError> for InkbLoaderError {
108    fn from(err: brink_format::DecodeError) -> Self {
109        Self::Decode(err)
110    }
111}
112
113impl AssetLoader for InkbLoader {
114    type Asset = BrinkStoryAsset;
115    type Settings = ();
116    type Error = InkbLoaderError;
117
118    async fn load(
119        &self,
120        reader: &mut dyn Reader,
121        _settings: &Self::Settings,
122        load_context: &mut LoadContext<'_>,
123    ) -> Result<Self::Asset, Self::Error> {
124        let mut bytes = Vec::new();
125        reader.read_to_end(&mut bytes).await?;
126        let story_data = brink_format::read_inkb(&bytes)?;
127        let (program, tables) = brink_runtime::link(&story_data)?;
128        Ok(emit_story_assets(
129            load_context,
130            program,
131            tables,
132            story_data.effect_rows,
133        ))
134    }
135
136    fn extensions(&self) -> &[&str] {
137        &["inkb"]
138    }
139}
140
141/// Compute the fresh starting [`World`](brink_runtime::World) for a program — globals seeded
142/// from `VAR`/`CONST`/`LIST` defaults, zero visit and turn counts. No
143/// execution; pure function of the linked program.
144pub(crate) fn fresh_context(program: &Program) -> World {
145    // FlowInstance::new_at_root constructs both a flow and a fresh
146    // World; we only want the World here.
147    let (_, context) = FlowInstance::new_at_root(program);
148    context
149}
150
151/// Emit the two labeled subassets (`#program`, `#line_tables`) and
152/// return the bundle holding their handles. The fresh starting
153/// [`World`](brink_runtime::World) is computed and stored inline on `ProgramAsset`.
154pub(crate) fn emit_story_assets(
155    load_context: &mut LoadContext<'_>,
156    program: Program,
157    tables: Vec<Vec<LineEntry>>,
158    effect_rows: Vec<EffectRowEntry>,
159) -> BrinkStoryAsset {
160    let initial_context = fresh_context(&program);
161    let program = load_context.add_labeled_asset(
162        "program".to_string(),
163        ProgramAsset {
164            program,
165            initial_context,
166            effect_rows,
167        },
168    );
169    let line_tables =
170        load_context.add_labeled_asset("line_tables".to_string(), LineTablesAsset { tables });
171    BrinkStoryAsset {
172        program,
173        line_tables,
174    }
175}
176
177/// Component holding the `Handle<ProgramAsset>` a [`BrinkFlow<M>`](crate::BrinkFlow)
178/// executes against.
179///
180/// In Bevy 0.19 `Handle<T>` is no longer a `Component` directly, so
181/// flow entities need a wrapper to associate a flow with its program.
182/// The fulfillment system inserts this (as part of [`BrinkStory`])
183/// when consuming a [`BrinkFlowRequest`](crate::BrinkFlowRequest);
184/// manual usage is possible but rare.
185#[derive(Component)]
186pub struct BrinkProgram<M: Send + Sync + 'static = ()> {
187    pub handle: Handle<ProgramAsset>,
188    _marker: PhantomData<fn() -> M>,
189}
190
191impl<M: Send + Sync + 'static> BrinkProgram<M> {
192    #[must_use]
193    pub fn new(handle: Handle<ProgramAsset>) -> Self {
194        Self {
195            handle,
196            _marker: PhantomData,
197        }
198    }
199}
200
201/// Bundle that pairs a flow's [`BrinkProgram`] (program handle) with
202/// its [`BrinkLocale`] (line-tables handle).
203///
204/// Inserted by `fulfill_flow_requests` as a single bundle so the two
205/// always travel together. Consumers can also spawn this directly if
206/// they're managing flows manually.
207///
208/// The two components stay individually queryable — `Changed<BrinkLocale<M>>`
209/// detects locale swaps without false positives from program changes.
210#[derive(Bundle)]
211pub struct BrinkStory<M: Send + Sync + 'static = ()> {
212    pub program: BrinkProgram<M>,
213    pub locale: BrinkLocale<M>,
214}
215
216impl<M: Send + Sync + 'static> BrinkStory<M> {
217    #[must_use]
218    pub fn new(program: Handle<ProgramAsset>, line_tables: Handle<LineTablesAsset>) -> Self {
219        Self {
220            program: BrinkProgram::new(program),
221            locale: BrinkLocale::new(line_tables),
222        }
223    }
224}
225
226#[cfg(test)]
227mod fresh_context_tests {
228    use crate::test_support::compile_test_story;
229
230    /// `VAR` defaults are a link-time concern (`Program::global_defaults`),
231    /// not an init-pass concern. The fresh World picks them up
232    /// without any execution.
233    #[test]
234    fn fresh_context_picks_up_var_defaults() {
235        let source = "VAR score = 42\n=== start ===\nHello.\n* [Continue] -> END\n";
236        let (program, tables, ctx) = compile_test_story(source);
237
238        let mut score_value = None;
239        for slot in 0..program.global_count() {
240            if program.global_name(slot) == Some("score") {
241                score_value = Some(ctx.globals[slot as usize].clone());
242            }
243        }
244        assert!(score_value.is_some(), "score global should exist");
245        assert!(
246            matches!(score_value.unwrap(), brink_format::Value::Int(42)),
247            "score should be 42 from the VAR default"
248        );
249        assert!(!tables.is_empty(), "compiled story should have line tables");
250    }
251}