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}