Skip to main content

bevy_brink/
call.rs

1//! Deferred engine→ink calls for non-exclusive systems.
2//!
3//! [`call_ink_function`](crate::call_ink_function) needs `&mut World`, so a
4//! normal system (which only has `Query`/`Res` access) can't use it
5//! directly. Instead it *requests* a call via
6//! [`brink_call`](BrinkCallCommandsExt::brink_call) and reacts to the
7//! result with an observer scoped to a unique per-call entity:
8//!
9//! ```no_run
10//! # use bevy_ecs::entity::Entity;
11//! # use bevy_ecs::observer::On;
12//! # use bevy_ecs::resource::Resource;
13//! # use bevy_ecs::system::{Commands, ResMut};
14//! # use bevy_brink::{BrinkCallCommandsExt, BrinkCallResolved};
15//! # #[derive(Resource, Default)]
16//! # struct PendingMoves;
17//! # impl PendingMoves {
18//! #     fn execute_queued(&mut self) {}
19//! # }
20//! # fn example(mut commands: Commands, flow_entity: Entity, in_combat: bool) {
21//! commands
22//!     .brink_call::<()>(flow_entity, "can_player_advance", (in_combat,))
23//!     .observe(|on: On<BrinkCallResolved>, mut moves: ResMut<PendingMoves>| {
24//!         if on.event().value.as_bool() == Some(true) {
25//!             moves.execute_queued();
26//!         }
27//!     });
28//! # }
29//! ```
30//!
31//! Each `brink_call` spawns its own entity; the plugin's exclusive
32//! [`resolve_brink_calls`] system evaluates the function (running any
33//! world-access query bindings) and fires [`BrinkCallResolved`] /
34//! [`BrinkCallFailed`] **targeted at that entity**, so the observer runs
35//! exactly once and can never be confused with another call's result. The
36//! call entity (and its observer) is despawned afterward.
37//!
38//! [`brink_call_batch`](BrinkCallCommandsExt::brink_call_batch) is the
39//! non-exclusive counterpart of [`call_ink_functions`](crate::call_ink_functions)
40//! (#1076): a normal system queues a whole ordered batch of calls at once
41//! (e.g. an event-folding system that hands a frame's worth of sightings to
42//! ink) instead of issuing one `brink_call` per call and paying a
43//! `SystemState` setup — and per-call resolution order — that isn't
44//! pinned across separate deferred requests. `brink_call_batch` spawns one
45//! request entity holding the whole ordered call list; the plugin's
46//! exclusive [`resolve_brink_call_batches`] system resolves the whole list
47//! through [`call_ink_functions`](crate::call_ink_functions) in a single
48//! call, which is what pins the front-to-back ordering and per-call
49//! isolation [`call_ink_functions`](crate::call_ink_functions) documents —
50//! not merely "these requests happen to run in the same frame." (The
51//! **single VM-eval setup** it also does is a separate amortization — one
52//! `SystemState` build instead of one per call — not the mechanism that
53//! pins ordering.) The whole
54//! batch's results (one `Result` per call, in call order — a failing call
55//! does not abort the batch, matching [`call_ink_functions`](crate::call_ink_functions)'s
56//! no-short-circuit contract) are delivered in one
57//! [`BrinkCallBatchResolved`] event at the call entity:
58//!
59//! ```no_run
60//! # use bevy_ecs::entity::Entity;
61//! # use bevy_ecs::observer::On;
62//! # use bevy_ecs::system::Commands;
63//! # use bevy_brink::{BrinkCallBatchResolved, BrinkCallCommandsExt, Value};
64//! # fn example(mut commands: Commands, flow_entity: Entity, dt: f32, amount: f32) {
65//! commands
66//!     .brink_call_batch::<()>(flow_entity, [
67//!         ("decay", vec![Value::Float(dt)]),
68//!         ("escalate_spotting", vec![Value::Float(amount)]),
69//!     ])
70//!     .observe(|on: On<BrinkCallBatchResolved>| {
71//!         for result in &on.event().results { /* … */ }
72//!     });
73//! # }
74//! ```
75//!
76//! Same-frame ordering *across* separate deferred requests (whether two
77//! `brink_call`s, two `brink_call_batch`es, or a mix, targeting the same
78//! flow) is **not** pinned by either resolver — each is a distinct ECS
79//! query result, and Bevy's per-archetype iteration order (not a
80//! documented guarantee) is all that governs it. This mirrors
81//! [`resolve_brink_calls`]'s pre-existing posture for concurrent single
82//! calls; `brink_call_batch` only pins ordering *within* the one batch a
83//! single deferred request carries. A host that needs a guaranteed order
84//! across several call groups targeting one flow in one frame should fold
85//! them into a single `brink_call_batch` (or call `call_ink_functions`
86//! directly from an exclusive system).
87
88use std::marker::PhantomData;
89
90use bevy_ecs::component::Component;
91use bevy_ecs::entity::Entity;
92use bevy_ecs::event::EntityEvent;
93use bevy_ecs::system::{Commands, EntityCommands};
94use bevy_ecs::world::World;
95use brink_format::Value;
96
97use crate::bindings::{call_ink_function, call_ink_functions};
98
99/// Converts call-site arguments into the ink argument vector. Implemented
100/// for `()`, tuples of `Into<Value>` (up to 4), `Vec<Value>`, and
101/// `&[Value]` — so both `(in_combat, 3)` and an explicit `&[..]` work.
102pub trait IntoBrinkArgs {
103    /// Produce the ink arguments in declaration order.
104    fn into_brink_args(self) -> Vec<Value>;
105}
106
107impl IntoBrinkArgs for () {
108    fn into_brink_args(self) -> Vec<Value> {
109        Vec::new()
110    }
111}
112
113impl IntoBrinkArgs for Vec<Value> {
114    fn into_brink_args(self) -> Vec<Value> {
115        self
116    }
117}
118
119impl IntoBrinkArgs for &[Value] {
120    fn into_brink_args(self) -> Vec<Value> {
121        self.to_vec()
122    }
123}
124
125macro_rules! impl_into_brink_args_tuple {
126    ($($T:ident $idx:tt),+) => {
127        impl<$($T: Into<Value>),+> IntoBrinkArgs for ($($T,)+) {
128            fn into_brink_args(self) -> Vec<Value> {
129                vec![$(self.$idx.into()),+]
130            }
131        }
132    };
133}
134
135impl_into_brink_args_tuple!(A 0);
136impl_into_brink_args_tuple!(A 0, B 1);
137impl_into_brink_args_tuple!(A 0, B 1, C 2);
138impl_into_brink_args_tuple!(A 0, B 1, C 2, D 3);
139
140/// A pending deferred engine→ink call. Spawned on its own entity by
141/// [`brink_call`](BrinkCallCommandsExt::brink_call); consumed by
142/// [`resolve_brink_calls`].
143#[derive(Component)]
144pub struct BrinkCallRequest<M: Send + Sync + 'static = ()> {
145    /// The flow entity to evaluate the function on.
146    pub target: Entity,
147    /// The ink function name.
148    pub name: String,
149    /// The arguments, in declaration order.
150    pub args: Vec<Value>,
151    _marker: PhantomData<fn() -> M>,
152}
153
154/// Fired (targeted at the per-call entity) when a deferred call succeeds.
155/// React with `.observe(|on: On<BrinkCallResolved>| …)` on the
156/// [`brink_call`](BrinkCallCommandsExt::brink_call) return value.
157#[derive(EntityEvent)]
158pub struct BrinkCallResolved<M: Send + Sync + 'static = ()> {
159    /// The per-call entity (the observer target).
160    pub entity: Entity,
161    /// The function's return value.
162    pub value: Value,
163    _marker: PhantomData<fn() -> M>,
164}
165
166impl<M: Send + Sync + 'static> BrinkCallResolved<M> {
167    pub(crate) fn new(entity: Entity, value: Value) -> Self {
168        Self {
169            entity,
170            value,
171            _marker: PhantomData,
172        }
173    }
174}
175
176/// Fired (targeted at the per-call entity) when a deferred call fails
177/// (unknown function, unbound world query, runtime error, …).
178#[derive(EntityEvent)]
179pub struct BrinkCallFailed<M: Send + Sync + 'static = ()> {
180    /// The per-call entity (the observer target).
181    pub entity: Entity,
182    /// Human-readable failure description.
183    pub error: String,
184    _marker: PhantomData<fn() -> M>,
185}
186
187impl<M: Send + Sync + 'static> BrinkCallFailed<M> {
188    pub(crate) fn new(entity: Entity, error: String) -> Self {
189        Self {
190            entity,
191            error,
192            _marker: PhantomData,
193        }
194    }
195}
196
197/// A pending deferred *batch* engine→ink call. Spawned on its own entity by
198/// [`brink_call_batch`](BrinkCallCommandsExt::brink_call_batch); consumed by
199/// [`resolve_brink_call_batches`].
200#[derive(Component)]
201pub struct BrinkCallBatchRequest<M: Send + Sync + 'static = ()> {
202    /// The flow entity to evaluate the batch on.
203    pub target: Entity,
204    /// The calls, in the order they must run.
205    pub calls: Vec<(String, Vec<Value>)>,
206    _marker: PhantomData<fn() -> M>,
207}
208
209/// Fired (targeted at the per-call entity) when a deferred batch call
210/// finishes. One entry per queued call, in call order — a failing call
211/// yields `Err` in its own slot rather than aborting the batch, matching
212/// [`call_ink_functions`](crate::call_ink_functions)'s no-short-circuit
213/// contract. React with `.observe(|on: On<BrinkCallBatchResolved>| …)` on
214/// the [`brink_call_batch`](BrinkCallCommandsExt::brink_call_batch) return
215/// value.
216#[derive(EntityEvent)]
217pub struct BrinkCallBatchResolved<M: Send + Sync + 'static = ()> {
218    /// The per-call entity (the observer target).
219    pub entity: Entity,
220    /// One result per queued call, in call order.
221    pub results: Vec<Result<Value, String>>,
222    _marker: PhantomData<fn() -> M>,
223}
224
225impl<M: Send + Sync + 'static> BrinkCallBatchResolved<M> {
226    pub(crate) fn new(entity: Entity, results: Vec<Result<Value, String>>) -> Self {
227        Self {
228            entity,
229            results,
230            _marker: PhantomData,
231        }
232    }
233}
234
235/// `Commands` extension for requesting a deferred engine→ink call.
236pub trait BrinkCallCommandsExt {
237    /// Request an ink function evaluation on `flow` (the flow entity),
238    /// returning the [`EntityCommands`] of the spawned per-call entity so
239    /// you can attach result observers:
240    ///
241    /// ```no_run
242    /// # use bevy_ecs::entity::Entity;
243    /// # use bevy_ecs::observer::On;
244    /// # use bevy_ecs::system::Commands;
245    /// # use bevy_brink::{BrinkCallCommandsExt, BrinkCallResolved};
246    /// # fn example(mut commands: Commands, flow: Entity) {
247    /// commands.brink_call::<()>(flow, "can_spawn", ())
248    ///     .observe(|on: On<BrinkCallResolved>| { /* use on.event().value */ });
249    /// # }
250    /// ```
251    ///
252    /// The result is delivered exactly once, scoped to the returned entity
253    /// — there is no way to mis-correlate it with another call.
254    fn brink_call<M: Send + Sync + 'static>(
255        &mut self,
256        flow: Entity,
257        name: impl Into<String>,
258        args: impl IntoBrinkArgs,
259    ) -> EntityCommands<'_>;
260
261    /// Request a deferred **batch** of ink function evaluations on `flow`,
262    /// run front-to-back in a single VM-eval setup — the non-exclusive
263    /// counterpart of [`call_ink_functions`](crate::call_ink_functions).
264    /// Returns the [`EntityCommands`] of the spawned per-batch entity so
265    /// you can attach a result observer:
266    ///
267    /// ```no_run
268    /// # use bevy_ecs::entity::Entity;
269    /// # use bevy_ecs::observer::On;
270    /// # use bevy_ecs::system::Commands;
271    /// # use bevy_brink::{BrinkCallBatchResolved, BrinkCallCommandsExt, Value};
272    /// # fn example(mut commands: Commands, flow: Entity, dt: f32, amount: f32) {
273    /// commands
274    ///     .brink_call_batch::<()>(flow, [
275    ///         ("decay", vec![Value::Float(dt)]),
276    ///         ("escalate_spotting", vec![Value::Float(amount)]),
277    ///     ])
278    ///     .observe(|on: On<BrinkCallBatchResolved>| { /* on.event().results */ });
279    /// # }
280    /// ```
281    ///
282    /// The whole batch's results (one `Result` per call, in call order) are
283    /// delivered exactly once, in one [`BrinkCallBatchResolved`] event
284    /// scoped to the returned entity. See the module docs for the ordering
285    /// guarantee this pins (within the batch) versus what it leaves
286    /// unpinned (across separate deferred requests).
287    fn brink_call_batch<M: Send + Sync + 'static>(
288        &mut self,
289        flow: Entity,
290        calls: impl IntoIterator<Item = (impl Into<String>, impl IntoBrinkArgs)>,
291    ) -> EntityCommands<'_>;
292}
293
294impl BrinkCallCommandsExt for Commands<'_, '_> {
295    fn brink_call<M: Send + Sync + 'static>(
296        &mut self,
297        flow: Entity,
298        name: impl Into<String>,
299        args: impl IntoBrinkArgs,
300    ) -> EntityCommands<'_> {
301        self.spawn(BrinkCallRequest::<M> {
302            target: flow,
303            name: name.into(),
304            args: args.into_brink_args(),
305            _marker: PhantomData,
306        })
307    }
308
309    fn brink_call_batch<M: Send + Sync + 'static>(
310        &mut self,
311        flow: Entity,
312        calls: impl IntoIterator<Item = (impl Into<String>, impl IntoBrinkArgs)>,
313    ) -> EntityCommands<'_> {
314        let calls = calls
315            .into_iter()
316            .map(|(name, args)| (name.into(), args.into_brink_args()))
317            .collect();
318        self.spawn(BrinkCallBatchRequest::<M> {
319            target: flow,
320            calls,
321            _marker: PhantomData,
322        })
323    }
324}
325
326/// Exclusive system (registered by the plugin) that resolves pending
327/// [`BrinkCallRequest<M>`]s: evaluates each function via
328/// [`call_ink_function`], fires [`BrinkCallResolved`] / [`BrinkCallFailed`]
329/// at the call entity, and despawns it.
330pub fn resolve_brink_calls<M: Send + Sync + 'static>(world: &mut World) {
331    let mut query = world.query::<(Entity, &BrinkCallRequest<M>)>();
332    let pending: Vec<(Entity, Entity, String, Vec<Value>)> = query
333        .iter(world)
334        .map(|(call_entity, req)| (call_entity, req.target, req.name.clone(), req.args.clone()))
335        .collect();
336
337    for (call_entity, target, name, args) in pending {
338        match call_ink_function::<M>(world, target, &name, &args) {
339            Ok(value) => {
340                world
341                    .entity_mut(call_entity)
342                    .trigger(|e| BrinkCallResolved::<M>::new(e, value));
343            }
344            Err(err) => {
345                let message = err.to_string();
346                world
347                    .entity_mut(call_entity)
348                    .trigger(|e| BrinkCallFailed::<M>::new(e, message));
349            }
350        }
351        world.despawn(call_entity);
352    }
353}
354
355/// One pending batch request snapshot: `(call entity, target flow, its
356/// ordered calls)`. Factored out of [`resolve_brink_call_batches`] purely
357/// to keep the collected `Vec`'s element type nameable (clippy
358/// `type_complexity`).
359type PendingBatch = (Entity, Entity, Vec<(String, Vec<Value>)>);
360
361/// Exclusive system (registered by the plugin) that resolves pending
362/// [`BrinkCallBatchRequest<M>`]s: evaluates each queued batch through
363/// [`call_ink_functions`] — one `SystemState` setup per batch, calls
364/// running front-to-back — fires [`BrinkCallBatchResolved`] at the batch
365/// entity with the full per-call result `Vec`, and despawns it.
366pub fn resolve_brink_call_batches<M: Send + Sync + 'static>(world: &mut World) {
367    let mut query = world.query::<(Entity, &BrinkCallBatchRequest<M>)>();
368    let pending: Vec<PendingBatch> = query
369        .iter(world)
370        .map(|(call_entity, req)| (call_entity, req.target, req.calls.clone()))
371        .collect();
372
373    for (call_entity, target, calls) in pending {
374        let results: Vec<Result<Value, String>> =
375            call_ink_functions::<M, _, _>(world, target, calls)
376                .into_iter()
377                .map(|result| result.map_err(|err| err.to_string()))
378                .collect();
379        world
380            .entity_mut(call_entity)
381            .trigger(|e| BrinkCallBatchResolved::<M>::new(e, results));
382        world.despawn(call_entity);
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::test_support::{add_story_assets, compile_test_story, make_test_app};
390    use crate::{BrinkBindingsAppExt, BrinkFlow, BrinkFlowRequest};
391    use bevy_app::Update;
392    use bevy_ecs::prelude::*;
393
394    #[derive(Component)]
395    struct Enemy;
396
397    fn enemy_count(In((_e, _args)): In<crate::BrinkQueryInput>, q: Query<&Enemy>) -> Value {
398        #[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
399        Value::Int(q.iter().count() as i32)
400    }
401
402    /// A deferred `brink_call` from a normal system resolves via the
403    /// plugin's exclusive system and delivers the value to a scoped
404    /// observer — exactly once.
405    #[test]
406    fn brink_call_resolves_to_observer() {
407        #[derive(Resource, Default)]
408        struct Result(Vec<bool>);
409
410        let mut app = make_test_app();
411        app.init_resource::<Result>();
412        app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);
413
414        let (program, tables, ctx) = compile_test_story(
415            "EXTERNAL enemy_count()\n-> END\n=== function can_spawn() ===\n~ return enemy_count() < 3\n",
416        );
417        let story = add_story_assets(&mut app, program, tables, ctx);
418        app.world_mut().spawn(Enemy);
419
420        let flow = app
421            .world_mut()
422            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
423            .id();
424        app.update(); // fulfill
425
426        // A normal (non-exclusive) system issues the deferred call.
427        let mut once = true;
428        app.add_systems(
429            Update,
430            move |mut commands: Commands, flows: Query<Entity, With<BrinkFlow<()>>>| {
431                if !once {
432                    return;
433                }
434                once = false;
435                if let Ok(f) = flows.single() {
436                    commands.brink_call::<()>(f, "can_spawn", ()).observe(
437                        |on: On<BrinkCallResolved<()>>, mut out: ResMut<Result>| {
438                            out.0.push(on.event().value.as_bool().unwrap_or(false));
439                        },
440                    );
441                }
442            },
443        );
444
445        // Tick 1: the system issues brink_call (spawns the call entity +
446        // observer). Tick 2: the exclusive resolver evaluates and fires
447        // BrinkCallResolved at the call entity; the observer records it.
448        app.update();
449        app.update();
450
451        let _ = flow;
452        let out = &app.world().resource::<Result>().0;
453        assert_eq!(
454            out.as_slice(),
455            [true],
456            "1 enemy < 3 → can_spawn true, delivered once"
457        );
458    }
459
460    /// A deferred `brink_call_batch` from a normal system resolves via the
461    /// plugin's exclusive [`resolve_brink_call_batches`] and delivers one
462    /// [`BrinkCallBatchResolved`] to a scoped observer, exactly once, with
463    /// results in call order. Also proves the batch's #1076 core property
464    /// end-to-end through the deferred path: a failing call (unknown
465    /// function) fails in its own slot without aborting the batch or
466    /// perturbing story state, a later call still sees an earlier call's
467    /// mutation, and — critically, since this is the reason the deferred
468    /// resolver must be exclusive — a **world-access `bind_brink_query`
469    /// binding** queued right after the failing slot still resolves
470    /// against the World, the same ordering/isolation `call_ink_functions`
471    /// guarantees for the exclusive path.
472    #[test]
473    fn brink_call_batch_resolves_ordered_results_to_observer() {
474        #[derive(Resource, Default)]
475        struct Result(Vec<Vec<std::result::Result<Value, String>>>);
476
477        let mut app = make_test_app();
478        app.init_resource::<Result>();
479        app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);
480        app.world_mut().spawn(Enemy);
481        app.world_mut().spawn(Enemy);
482
483        let (program, tables, ctx) = compile_test_story(
484            "EXTERNAL enemy_count()\nVAR total = 0\n-> END\n\
485             === function add(n) ===\n~ total = total + n\n~ return total\n\
486             === function get() ===\n~ return total\n\
487             === function seen() ===\n~ return enemy_count()\n",
488        );
489        let story = add_story_assets(&mut app, program, tables, ctx);
490        app.world_mut()
491            .spawn(BrinkFlowRequest::<()>::builder().story(story).build());
492        app.update(); // fulfill
493
494        // A normal (non-exclusive) system issues the deferred batch call.
495        let mut once = true;
496        app.add_systems(
497            Update,
498            move |mut commands: Commands, flows: Query<Entity, With<BrinkFlow<()>>>| {
499                if !once {
500                    return;
501                }
502                once = false;
503                if let Ok(f) = flows.single() {
504                    commands
505                        .brink_call_batch::<()>(
506                            f,
507                            [
508                                ("add", vec![Value::Int(1)]),
509                                ("nope", vec![]), // unknown fn — must not abort the batch
510                                // A world-access query call, queued right after the
511                                // failing slot: proves the failure didn't wedge the
512                                // batch's shared SystemState/query access.
513                                ("seen", vec![]),
514                                ("add", vec![Value::Int(10)]),
515                                ("get", vec![]),
516                            ],
517                        )
518                        .observe(
519                            |on: On<BrinkCallBatchResolved<()>>, mut out: ResMut<Result>| {
520                                out.0.push(on.event().results.clone());
521                            },
522                        );
523                }
524            },
525        );
526
527        // Tick 1: the system issues brink_call_batch (spawns the request
528        // entity + observer). Tick 2: the exclusive resolver evaluates the
529        // whole batch in one VM-eval setup and fires BrinkCallBatchResolved
530        // at the request entity; the observer records it.
531        app.update();
532        app.update();
533
534        let out = &app.world().resource::<Result>().0;
535        assert_eq!(out.len(), 1, "delivered exactly once");
536        let results = &out[0];
537        assert_eq!(results.len(), 5, "one slot per call, no drops");
538        assert_eq!(results[0].as_ref().unwrap(), &Value::Int(1));
539        assert!(
540            results[1].is_err(),
541            "the bad call fails in its own slot; got {:?}",
542            results[1]
543        );
544        // The world-access query call right after the failing slot still
545        // resolves against the World (2 enemies spawned above).
546        assert_eq!(
547            results[2].as_ref().unwrap(),
548            &Value::Int(2),
549            "a query-backed call still runs post-error"
550        );
551        // The failed call did not perturb `total`: the next add sees 1, not 0.
552        assert_eq!(results[3].as_ref().unwrap(), &Value::Int(11));
553        assert_eq!(results[4].as_ref().unwrap(), &Value::Int(11));
554    }
555}