Skip to main content

bevy_brink/
async_bind.rs

1//! Asynchronous ink → engine external bindings: resolve **across frames**.
2//!
3//! A synchronous `bind_brink_query` resolves in one resolver pass. Some
4//! externals can't: a `pick_target()` that opens a targeting UI and waits for
5//! a click, or an `expensive_roll()` that runs off-thread. These *park* the
6//! flow on a pending external (the runtime's `AwaitingExternal`/
7//! `resolve_external` pause/resume) and resolve it whenever the work finishes.
8//!
9//! Two registration verbs (on [`BrinkBindingsAppExt`](crate::BrinkBindingsAppExt)):
10//!
11//! - **`bind_brink_async`** — the primitive. When ink calls the external the
12//!   flow parks and [`BrinkExternalAwaited`] fires (once) at the flow entity.
13//!   An observer does whatever multi-frame work it needs (UI, input, world
14//!   state) and eventually calls
15//!   [`resolve_brink_external`](BrinkResolveExternalExt::resolve_brink_external).
16//! - **`bind_brink_task`** — sugar over [`bevy_tasks::AsyncComputeTaskPool`]:
17//!   bevy-brink spawns the future, parks a [`BrinkPendingTask`] on the flow,
18//!   and [`poll_brink_tasks`] resolves it when the task completes. The future
19//!   is `Send + 'static` and computes from the ink args only (no World access);
20//!   use the event primitive for World-dependent async.
21//!
22//! **Correlation is the flow entity.** A flow parks on exactly one external and
23//! is frozen until resolved, so the entity is the unambiguous key — no
24//! per-call entity or correlation id is needed (unlike `commands.brink_call`).
25
26use std::marker::PhantomData;
27
28use bevy_ecs::component::Component;
29use bevy_ecs::entity::Entity;
30use bevy_ecs::event::EntityEvent;
31use bevy_ecs::system::{Commands, Query};
32use bevy_ecs::world::World;
33use bevy_log::warn;
34use bevy_tasks::{Task, block_on, poll_once};
35use brink_format::Value;
36
37use crate::flow::BrinkFlow;
38
39/// Fired (once, targeted at the flow entity) when a flow parks on a
40/// [`bind_brink_async`](crate::BrinkBindingsAppExt::bind_brink_async) external.
41///
42/// React with a global observer or `entity.observe(...)`, kick off whatever
43/// multi-frame work the external represents, and resolve with
44/// [`resolve_brink_external`](BrinkResolveExternalExt::resolve_brink_external):
45///
46/// ```no_run
47/// # use bevy_app::App;
48/// # use bevy_ecs::observer::On;
49/// # use bevy_ecs::system::Commands;
50/// # use bevy_brink::{BrinkExternalAwaited, BrinkResolveExternalExt, Value};
51/// # let mut app = App::new();
52/// app.add_observer(|on: On<BrinkExternalAwaited>, mut commands: Commands| {
53///     if on.event().name == "pick_target" {
54///         // … open UI; later, when the player picks target 7:
55///         commands.resolve_brink_external::<()>(on.event().entity, Value::Int(7));
56///     }
57/// });
58/// ```
59#[derive(EntityEvent)]
60pub struct BrinkExternalAwaited<M: Send + Sync + 'static = ()> {
61    /// The flow entity awaiting resolution (the observer target).
62    pub entity: Entity,
63    /// The external function name ink called.
64    pub name: String,
65    /// The ink call arguments, in declaration order.
66    pub args: Vec<Value>,
67    _marker: PhantomData<fn() -> M>,
68}
69
70impl<M: Send + Sync + 'static> BrinkExternalAwaited<M> {
71    pub(crate) fn new(entity: Entity, name: String, args: Vec<Value>) -> Self {
72        Self {
73            entity,
74            name,
75            args,
76            _marker: PhantomData,
77        }
78    }
79}
80
81/// Marker inserted on a flow while it awaits a `bind_brink_async` external.
82///
83/// Its presence makes the dispatcher fire [`BrinkExternalAwaited`] exactly
84/// once; [`resolve_brink_external`](BrinkResolveExternalExt::resolve_brink_external)
85/// removes it on resolution.
86#[derive(Component)]
87pub struct BrinkAwaiting<M: Send + Sync + 'static = ()> {
88    /// The external name being awaited.
89    pub name: String,
90    _marker: PhantomData<fn() -> M>,
91}
92
93impl<M: Send + Sync + 'static> BrinkAwaiting<M> {
94    pub(crate) fn new(name: String) -> Self {
95        Self {
96            name,
97            _marker: PhantomData,
98        }
99    }
100}
101
102/// A detached [`Task`] computing a [`bind_brink_task`](crate::BrinkBindingsAppExt::bind_brink_task)
103/// external's value, parked on the flow entity.
104///
105/// [`poll_brink_tasks`] polls it each frame; when it finishes, the flow's
106/// pending external is resolved with the value and this component is removed.
107#[derive(Component)]
108pub struct BrinkPendingTask<M: Send + Sync + 'static = ()> {
109    pub(crate) task: Task<Value>,
110    /// External name + args, kept in dev builds so [`poll_brink_tasks`] can
111    /// record the task's result into the flow's replay log on completion (the
112    /// value isn't available until the future finishes).
113    #[cfg(feature = "dev")]
114    name: String,
115    #[cfg(feature = "dev")]
116    args: Vec<Value>,
117    _marker: PhantomData<fn() -> M>,
118}
119
120impl<M: Send + Sync + 'static> BrinkPendingTask<M> {
121    pub(crate) fn new(
122        task: Task<Value>,
123        #[cfg(feature = "dev")] name: String,
124        #[cfg(feature = "dev")] args: Vec<Value>,
125    ) -> Self {
126        Self {
127            task,
128            #[cfg(feature = "dev")]
129            name,
130            #[cfg(feature = "dev")]
131            args,
132            _marker: PhantomData,
133        }
134    }
135}
136
137/// [`Commands`] extension to resolve a flow's awaited async external.
138pub trait BrinkResolveExternalExt {
139    /// Resolve the (single) external that `flow` is parked on with `value`,
140    /// removing the [`BrinkAwaiting`] marker so the flow resumes on its next
141    /// step. A `warn!` no-op if the flow isn't actually awaiting one (stale or
142    /// double resolve) — the flow entity is the unambiguous key, since a flow
143    /// parks on exactly one external at a time.
144    fn resolve_brink_external<M: Send + Sync + 'static>(&mut self, flow: Entity, value: Value);
145}
146
147impl BrinkResolveExternalExt for Commands<'_, '_> {
148    fn resolve_brink_external<M: Send + Sync + 'static>(&mut self, flow: Entity, value: Value) {
149        self.queue(move |world: &mut World| {
150            resolve_external_world::<M>(world, flow, value);
151        });
152    }
153}
154
155/// Resolve a flow's pending async external from an exclusive `&mut World`
156/// context. Guarded by `has_pending_external()` so stale/double resolves are
157/// safe no-ops. Removes the [`BrinkAwaiting`] marker on success.
158pub(crate) fn resolve_external_world<M: Send + Sync + 'static>(
159    world: &mut World,
160    flow: Entity,
161    value: Value,
162) {
163    // Capture the external name (from BrinkAwaiting) + args (while still parked)
164    // before we consume `value`, so we can record the resolution into the flow's
165    // replay log (dev) for faithful hot-reload replay.
166    #[cfg(feature = "dev")]
167    let record_info = {
168        let name = world.get::<BrinkAwaiting<M>>(flow).map(|a| a.name.clone());
169        let args = world
170            .get::<BrinkFlow<M>>(flow)
171            .filter(|f| f.inner.has_pending_external())
172            .map(|f| f.inner.pending_external_args().to_vec());
173        name.zip(args).map(|(n, a)| (n, a, value.clone()))
174    };
175
176    let resolved = {
177        let mut flows = world.query::<&mut BrinkFlow<M>>();
178        match flows.get_mut(world, flow) {
179            Ok(mut f) if f.inner.has_pending_external() => {
180                f.inner.resolve_external(value);
181                true
182            }
183            Ok(_) => {
184                warn!(
185                    "resolve_brink_external on {flow:?}: flow has no pending external \
186                     (already resolved?); ignoring"
187                );
188                false
189            }
190            Err(_) => {
191                warn!("resolve_brink_external on {flow:?}: not a brink flow; ignoring");
192                false
193            }
194        }
195    };
196    if resolved {
197        world.entity_mut(flow).remove::<BrinkAwaiting<M>>();
198        #[cfg(feature = "dev")]
199        if let Some((name, args, recorded)) = record_info {
200            crate::replay::record_external::<M>(world, flow, &name, &args, &recorded);
201        }
202    }
203}
204
205/// Plugin system: poll detached [`bind_brink_task`](crate::BrinkBindingsAppExt::bind_brink_task)
206/// futures; when one finishes, resolve its flow's pending external with the
207/// value and drop the [`BrinkPendingTask`]. Polling is non-blocking
208/// (`poll_once`). The plugin gates this on `any_with_component::<BrinkPendingTask<M>>`.
209pub fn poll_brink_tasks<M: Send + Sync + 'static>(
210    mut tasks: Query<(Entity, &mut BrinkPendingTask<M>, &mut BrinkFlow<M>)>,
211    mut commands: Commands,
212) {
213    for (entity, mut pending, mut flow) in &mut tasks {
214        if let Some(value) = block_on(poll_once(&mut pending.task)) {
215            // Guard: the flow could have been resolved by other means.
216            if flow.inner.has_pending_external() {
217                // Record the resolved value into the flow's replay log (dev)
218                // for faithful hot-reload replay. Deferred via a command so we
219                // don't need `BrinkReplayLog` in this non-exclusive query.
220                #[cfg(feature = "dev")]
221                {
222                    let (name, args, recorded) =
223                        (pending.name.clone(), pending.args.clone(), value.clone());
224                    commands.queue(move |world: &mut World| {
225                        crate::replay::record_external::<M>(world, entity, &name, &args, &recorded);
226                    });
227                }
228                flow.inner.resolve_external(value);
229            }
230            commands.entity(entity).remove::<BrinkPendingTask<M>>();
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::asset::{LineTablesAsset, ProgramAsset};
239    use crate::test_support::{add_story_assets, compile_test_story, make_test_app};
240    use crate::{
241        Advance, BrinkBindings, BrinkBindingsAppExt, BrinkContext, BrinkFlowRequest, BrinkLocale,
242        BrinkProgram, advance_flow,
243    };
244    use bevy_app::{App, Update};
245    use bevy_asset::Assets;
246    use bevy_ecs::prelude::*;
247
248    #[derive(Resource, Default)]
249    struct Lines(Vec<String>);
250
251    /// A normal (non-exclusive) flow driver: step each flow once per frame,
252    /// skipping flows parked on a pending external (the plugin's
253    /// `resolve_pending_externals` / `poll_brink_tasks` service those; we
254    /// resume on a later frame). Mirrors the real consumer pattern.
255    #[expect(
256        clippy::type_complexity,
257        clippy::needless_pass_by_value,
258        reason = "bevy systems take their params (Query/Res) by value"
259    )]
260    fn step_driver(
261        mut flows: Query<(
262            Entity,
263            &mut BrinkFlow<()>,
264            &mut BrinkContext<()>,
265            &BrinkProgram<()>,
266            &BrinkLocale<()>,
267        )>,
268        globals: Option<ResMut<crate::BrinkGlobals<()>>>,
269        programs: Res<Assets<ProgramAsset>>,
270        tables: Res<Assets<LineTablesAsset>>,
271        bindings: Res<BrinkBindings<()>>,
272        mut commands: Commands,
273        mut out: ResMut<Lines>,
274    ) {
275        let Some(mut globals) = globals else {
276            return;
277        };
278        for (entity, mut flow, mut ctx, prog, loc) in &mut flows {
279            if flow.inner.has_pending_external() {
280                continue;
281            }
282            let (Some(p), Some(t)) = (programs.get(&prog.handle), tables.get(&loc.handle)) else {
283                continue;
284            };
285            let handler = bindings.handler();
286            let mut view = crate::globals::flow_context_view(&mut globals, &mut ctx);
287            if let Ok(Advance::Step(line)) = flow.step_one(
288                &p.program,
289                &t.tables,
290                &mut view,
291                &handler,
292                entity,
293                &mut commands,
294            ) {
295                out.0.push(line.text().to_string());
296            }
297            handler.flush(&mut commands);
298        }
299    }
300
301    fn spawn_flow(app: &mut App, src: &str) -> Entity {
302        let (program, tables, ctx) = compile_test_story(src);
303        let story = add_story_assets(app, program, tables, ctx);
304        let entity = app
305            .world_mut()
306            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
307            .id();
308        app.update(); // fulfill the request
309        entity
310    }
311
312    fn pending(app: &App, flow: Entity) -> bool {
313        app.world()
314            .entity(flow)
315            .get::<BrinkFlow<()>>()
316            .is_some_and(|f| f.inner.has_pending_external())
317    }
318
319    /// A `bind_brink_task` external parks the flow; bevy-brink spawns the
320    /// future, `poll_brink_tasks` resolves it when the task finishes, and the
321    /// flow resumes with the computed value.
322    #[test]
323    fn task_binding_resolves_across_frames() {
324        let mut app = make_test_app();
325        app.init_resource::<Lines>();
326        app.add_systems(Update, step_driver);
327        app.bind_brink_task::<(), _, _>("expensive_roll", |args: Vec<Value>| async move {
328            let n = args.first().and_then(Value::as_int).unwrap_or(0);
329            Value::Int(n * 2)
330        });
331
332        let flow = spawn_flow(
333            &mut app,
334            "EXTERNAL expensive_roll(n)\nRolled: {expensive_roll(21)}.\n-> END\n",
335        );
336
337        // Drive until the resolved line appears (cap to avoid hangs).
338        let mut got = false;
339        for _ in 0..200 {
340            app.update();
341            if app
342                .world()
343                .resource::<Lines>()
344                .0
345                .iter()
346                .any(|l| l.contains("Rolled: 42."))
347            {
348                got = true;
349                break;
350            }
351        }
352        assert!(
353            got,
354            "task should resolve to 42 and resume the flow; got {:?}",
355            app.world().resource::<Lines>().0
356        );
357        assert!(!pending(&app, flow), "flow no longer parked after resolve");
358    }
359
360    /// A `bind_brink_async` external fires `BrinkExternalAwaited` exactly once;
361    /// an observer resolves it via `resolve_brink_external`; the flow resumes.
362    #[test]
363    fn async_event_binding_fires_once_and_resolves() {
364        #[derive(Resource, Default)]
365        struct Awaited(Vec<String>);
366
367        let mut app = make_test_app();
368        app.init_resource::<Lines>();
369        app.init_resource::<Awaited>();
370        app.add_systems(Update, step_driver);
371        app.bind_brink_async::<()>("pick_target");
372        app.add_observer(
373            |on: On<BrinkExternalAwaited<()>>, mut commands: Commands, mut log: ResMut<Awaited>| {
374                log.0.push(on.event().name.clone());
375                commands.resolve_brink_external::<()>(on.event().entity, Value::Int(7));
376            },
377        );
378
379        spawn_flow(
380            &mut app,
381            "EXTERNAL pick_target()\nYou aim at {pick_target()}.\n-> END\n",
382        );
383
384        let mut got = false;
385        for _ in 0..50 {
386            app.update();
387            if app
388                .world()
389                .resource::<Lines>()
390                .0
391                .iter()
392                .any(|l| l.contains("You aim at 7."))
393            {
394                got = true;
395                break;
396            }
397        }
398        assert!(
399            got,
400            "observer should resolve pick_target to 7 and resume; got {:?}",
401            app.world().resource::<Lines>().0
402        );
403        assert_eq!(
404            app.world().resource::<Awaited>().0,
405            vec!["pick_target".to_string()],
406            "BrinkExternalAwaited fires exactly once"
407        );
408    }
409
410    /// While parked on a `bind_brink_async` external with no resolution, the
411    /// flow stays frozen: the event fires only once and `has_pending_external`
412    /// stays true across many frames (no advancement, no re-fire).
413    #[test]
414    fn async_event_binding_stays_frozen_until_resolved() {
415        #[derive(Resource, Default)]
416        struct FireCount(usize);
417
418        let mut app = make_test_app();
419        app.init_resource::<Lines>();
420        app.init_resource::<FireCount>();
421        app.add_systems(Update, step_driver);
422        app.bind_brink_async::<()>("pick_target");
423        // Observer counts but never resolves.
424        app.add_observer(
425            |_on: On<BrinkExternalAwaited<()>>, mut n: ResMut<FireCount>| {
426                n.0 += 1;
427            },
428        );
429
430        let flow = spawn_flow(
431            &mut app,
432            "EXTERNAL pick_target()\nYou aim at {pick_target()}.\n-> END\n",
433        );
434
435        for _ in 0..20 {
436            app.update();
437        }
438
439        assert!(pending(&app, flow), "flow stays parked without resolution");
440        assert_eq!(
441            app.world().resource::<FireCount>().0,
442            1,
443            "event fires once, not per frame"
444        );
445        assert!(
446            !app.world()
447                .resource::<Lines>()
448                .0
449                .iter()
450                .any(|l| l.contains("aim at")),
451            "no resolved line while frozen"
452        );
453    }
454
455    /// The one-pass exclusive driver can't await an async external — it returns
456    /// a clear `AsyncExternalUnsupported` rather than `UnknownQuery`.
457    #[test]
458    fn advance_flow_rejects_async_external() {
459        let mut app = make_test_app();
460        app.bind_brink_async::<()>("pick_target");
461
462        let flow = spawn_flow(
463            &mut app,
464            "EXTERNAL pick_target()\nYou aim at {pick_target()}.\n-> END\n",
465        );
466
467        let err = advance_flow::<()>(app.world_mut(), flow).unwrap_err();
468        assert!(
469            matches!(err, crate::BrinkCallError::AsyncExternalUnsupported(ref n) if n == "pick_target"),
470            "got {err:?}"
471        );
472    }
473}