Skip to main content

bevy_brink/bindings/
drive.rs

1//! Drive/eval API: the systems that actually step flows and resolve pending
2//! externals against the World.
3//!
4//! Two exclusive (`&mut World`) drivers live here: [`call_ink_function`] (and
5//! its batch/function-value siblings) evaluates an ink function from engine
6//! code without touching the visible story; [`advance_flow`] advances a
7//! flow's playback by one line, resolving world-access query bindings inline.
8//! [`resolve_pending_externals`] is the plugin system that services flows
9//! parked on a pending external during normal (non-exclusive) `step_one`
10//! playback. See the parent module's docs (`crate::bindings`) for the
11//! conceptual overview of the three synchronous binding kinds plus the async
12//! ones these drivers hand off.
13
14use std::future::Future;
15use std::pin::Pin;
16
17use bevy_asset::Assets;
18use bevy_ecs::entity::Entity;
19use bevy_ecs::system::{Query, Res, ResMut, SystemState};
20use bevy_ecs::world::World;
21use bevy_log::warn;
22use bevy_tasks::{AsyncComputeTaskPool, TaskPool};
23use brink_format::Value;
24use brink_runtime::{FastRng, FlowInstance, Program, RuntimeError, Step, StepOutcome};
25#[cfg(feature = "dev")]
26use brink_runtime::{RecordingHandler, ReplayRecorder};
27use thiserror::Error;
28
29use crate::asset::{BrinkProgram, LineTablesAsset, ProgramAsset};
30use crate::async_bind::{BrinkAwaiting, BrinkExternalAwaited, BrinkPendingTask};
31use crate::flow::BrinkFlow;
32use crate::globals::BrinkContext;
33use crate::line_tables::BrinkLocale;
34
35use super::registration::{AsyncKind, BrinkBindings, BrinkHandler, QuerySystemId, TriggerFn};
36
37/// Errors from an engine→ink call ([`call_ink_function`]).
38#[derive(Debug, Error)]
39pub enum BrinkCallError {
40    /// The entity isn't a fulfilled flow (missing `BrinkFlow`/`BrinkProgram`/
41    /// `BrinkLocale`/`BrinkContext`).
42    #[error("entity is not a fulfilled brink flow")]
43    NotAFlow,
44    /// The flow's program asset isn't loaded.
45    #[error("program asset not loaded")]
46    ProgramNotLoaded,
47    /// The flow's line-tables asset isn't loaded.
48    #[error("line tables asset not loaded")]
49    LineTablesNotLoaded,
50    /// No function with this name exists in the program.
51    #[error("function '{0}' not found")]
52    FunctionNotFound(String),
53    /// The function called a world-access external with no registered
54    /// query binding (and no in-story fallback).
55    #[error("no query binding registered for external '{0}'")]
56    UnknownQuery(String),
57    /// The function called an **async** external (`bind_brink_async` /
58    /// `bind_brink_task`), which can't resolve in a single `&mut World` pass.
59    /// Drive such stories via the `step_one` playback path + the plugin's
60    /// `resolve_pending_externals` resolver instead.
61    #[error("external '{0}' is async; drive the flow via step_one, not the exclusive driver")]
62    AsyncExternalUnsupported(String),
63    /// A query binding's system failed to run.
64    #[error("query binding system failed: {0}")]
65    QueryFailed(String),
66    /// The `SystemState` snapshot of flow components + assets + bindings
67    /// failed to validate against the world (e.g. a required resource,
68    /// like `Assets<ProgramAsset>`, isn't present — the plugin wasn't added).
69    #[error("system param validation failed: {0}")]
70    SystemParamInvalid(String),
71    /// The runtime raised an error during evaluation.
72    #[error(transparent)]
73    Runtime(#[from] RuntimeError),
74}
75
76/// The next thing the [`call_ink_function`] driver must do.
77enum NextStep {
78    /// The function returned this value — evaluation is complete.
79    Done(Value),
80    /// The function is awaiting a world-access query; run this system with
81    /// the given ink args, resolve, and resume.
82    RunQuery {
83        system: QuerySystemId,
84        qargs: Vec<Value>,
85    },
86}
87
88/// The `SystemState` param bundle the engine→ink eval driver re-borrows each
89/// suspension: the flow components, the (optional) globals resource, the two
90/// asset stores, and the bindings registry. Factored out so
91/// [`call_ink_function`] (by name) and [`call_ink_function_value`] (by opaque
92/// function-value token) share one driver.
93type EvalSystemState<M> = SystemState<(
94    Query<
95        'static,
96        'static,
97        (
98            &'static BrinkProgram<M>,
99            &'static BrinkLocale<M>,
100            &'static mut BrinkFlow<M>,
101            &'static mut BrinkContext<M>,
102        ),
103    >,
104    Option<ResMut<'static, crate::BrinkGlobals<M>>>,
105    Res<'static, Assets<ProgramAsset>>,
106    Res<'static, Assets<LineTablesAsset>>,
107    Res<'static, BrinkBindings<M>>,
108)>;
109
110/// Drive an in-progress function evaluation to completion: run each pending
111/// world-access query against the World (borrows released between calls),
112/// resolve it, and resume — until the function returns its value. Shared by
113/// [`call_ink_function`] and [`call_ink_function_value`]; the only difference
114/// between the two callers is how the evaluation *begins* (by name vs by
115/// opaque function-value token), which produces the initial `NextStep`.
116fn drive_function_eval_to_done<M: Send + Sync + 'static>(
117    world: &mut World,
118    entity: Entity,
119    state: &mut EvalSystemState<M>,
120    mut next: NextStep,
121    triggers: &mut Vec<TriggerFn>,
122) -> Result<Value, BrinkCallError> {
123    loop {
124        match next {
125            NextStep::Done(value) => return Ok(value),
126            NextStep::RunQuery { system, qargs } => {
127                let value = world
128                    .run_system_with(system, (entity, qargs))
129                    .map_err(|e| BrinkCallError::QueryFailed(format!("{e:?}")))?;
130                next = {
131                    let (mut flows, globals, programs, tables, bindings) = state
132                        .get_mut(world)
133                        .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
134                    let mut globals = globals.ok_or(BrinkCallError::NotAFlow)?;
135                    let (prog_c, loc_c, mut flow, mut ctx) = flows
136                        .get_mut(entity)
137                        .map_err(|_| BrinkCallError::NotAFlow)?;
138                    let program = &programs
139                        .get(&prog_c.handle)
140                        .ok_or(BrinkCallError::ProgramNotLoaded)?
141                        .program;
142                    let line_tables = &tables
143                        .get(&loc_c.handle)
144                        .ok_or(BrinkCallError::LineTablesNotLoaded)?
145                        .tables;
146                    let handler = bindings.eval_handler();
147                    flow.inner.resolve_external(value);
148                    let mut view = crate::globals::flow_context_view(&mut globals, &mut ctx);
149                    let outcome = flow.inner.resume_function_eval::<FastRng>(
150                        program,
151                        line_tables,
152                        &mut view,
153                        &handler,
154                        None,
155                    )?;
156                    triggers.extend(handler.take_queued());
157                    classify_eval(&flow.inner, program, &bindings, outcome)?
158                };
159            }
160        }
161    }
162}
163
164/// Fire buffered command-event triggers from a completed engine→ink eval
165/// pass directly against the World — the exclusive driver's equivalent of
166/// [`BrinkHandler::flush`], since [`call_ink_function`] and friends already
167/// hold `&mut World` rather than a deferred [`bevy_ecs::system::Commands`]
168/// queue. Called only once evaluation reaches [`NextStep::Done`]; a mid-eval
169/// error drops any triggers queued so far, matching [`advance_flow`]'s
170/// existing drop-on-error precedent for buffered command triggers.
171///
172/// **Ordering this locks in:** because triggers only fire here, at the very
173/// end of the call, a `bind_brink_query` invoked *later* in the *same* call
174/// always runs (via `run_system_with` in [`drive_function_eval_to_done`])
175/// **before** any command trigger buffered earlier in that same call is
176/// fired — a query can never observe a command's World effects within one
177/// call. This is consistent with [`advance_flow`], which likewise flushes
178/// its triggers only once a line is produced, not between suspensions; it's
179/// a non-obvious consequence of the buffer-then-flush shape, not a defect.
180/// (Across separate calls — e.g. [`call_ink_functions`]'s per-call flush —
181/// a later call's query *does* see an earlier call's command effects, since
182/// each call flushes before the next begins.)
183fn flush_eval_triggers(world: &mut World, triggers: Vec<TriggerFn>) {
184    for trigger in triggers {
185        trigger(world);
186    }
187}
188
189/// Classify a [`FunctionEval`] outcome into the driver's [`NextStep`],
190/// looking up the query system for a pending external. Called inside the
191/// borrow scope where `flow`/`program`/`bindings` are available.
192fn classify_eval<M: Send + Sync + 'static>(
193    flow: &FlowInstance,
194    program: &Program,
195    bindings: &BrinkBindings<M>,
196    outcome: brink_runtime::FunctionEval,
197) -> Result<NextStep, BrinkCallError> {
198    match outcome {
199        brink_runtime::FunctionEval::Returned(value) => Ok(NextStep::Done(value)),
200        brink_runtime::FunctionEval::AwaitingExternal => {
201            let name = flow
202                .pending_external_name(program)
203                .unwrap_or_default()
204                .to_owned();
205            if bindings.async_bindings.contains_key(&name) {
206                return Err(BrinkCallError::AsyncExternalUnsupported(name));
207            }
208            let system = bindings
209                .query(&name)
210                .ok_or(BrinkCallError::UnknownQuery(name))?;
211            let qargs = flow.pending_external_args().to_vec();
212            Ok(NextStep::RunQuery { system, qargs })
213        }
214    }
215}
216
217/// Synchronously evaluate an ink function on a flow entity from an
218/// exclusive (`&mut World`) context, returning its value.
219///
220/// Pure bindings resolve inline; world-access (`bind_brink_query`) bindings
221/// are run via `run_system_with` between evaluation suspensions — so the
222/// function can query anything in the World. The whole call completes in
223/// one pass (one frame): the function's output is isolated, the
224/// player-visible story is untouched, and visit counts aren't bumped.
225///
226/// `M` is the story marker (use `()` for the default). For callers that
227/// don't have `&mut World` (a normal system), use the deferred
228/// `commands.brink_call(...)` API instead.
229///
230/// # Errors
231/// See [`BrinkCallError`].
232pub fn call_ink_function<M: Send + Sync + 'static>(
233    world: &mut World,
234    entity: Entity,
235    name: &str,
236    args: &[Value],
237) -> Result<Value, BrinkCallError> {
238    let mut state: EvalSystemState<M> = SystemState::new(world);
239    let mut triggers: Vec<TriggerFn> = Vec::new();
240
241    // Begin the evaluation (resolve the function by name, then start it).
242    let next = begin_eval_by_name(world, entity, &mut state, name, args, &mut triggers)?;
243
244    // Drive: run each pending world-access query against the World, resolve
245    // it, and resume — until the function returns.
246    let value = drive_function_eval_to_done(world, entity, &mut state, next, &mut triggers)?;
247
248    // Fire any command-event triggers the call queued along the way (#1096).
249    flush_eval_triggers(world, triggers);
250    Ok(value)
251}
252
253/// Begin one by-name function evaluation against an already-built
254/// [`EvalSystemState`], resolving the function and taking its first step.
255///
256/// Factored out of [`call_ink_function`] so the single-call path and the
257/// batch path ([`call_ink_functions`]) share identical begin semantics while
258/// the batch reuses **one** `SystemState` across every call — the setup
259/// (`SystemState::new`) is paid once per batch turn, not once per call.
260fn begin_eval_by_name<M: Send + Sync + 'static>(
261    world: &mut World,
262    entity: Entity,
263    state: &mut EvalSystemState<M>,
264    name: &str,
265    args: &[Value],
266    triggers: &mut Vec<TriggerFn>,
267) -> Result<NextStep, BrinkCallError> {
268    let (mut flows, globals, programs, tables, bindings) = state
269        .get_mut(world)
270        .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
271    let mut globals = globals.ok_or(BrinkCallError::NotAFlow)?;
272    let (prog_c, loc_c, mut flow, mut ctx) = flows
273        .get_mut(entity)
274        .map_err(|_| BrinkCallError::NotAFlow)?;
275    let program = &programs
276        .get(&prog_c.handle)
277        .ok_or(BrinkCallError::ProgramNotLoaded)?
278        .program;
279    let line_tables = &tables
280        .get(&loc_c.handle)
281        .ok_or(BrinkCallError::LineTablesNotLoaded)?
282        .tables;
283    let idx = program
284        .find_address(name)
285        .ok_or_else(|| BrinkCallError::FunctionNotFound(name.to_owned()))?
286        .0;
287    let handler = bindings.eval_handler();
288    let mut view = crate::globals::flow_context_view(&mut globals, &mut ctx);
289    let outcome = flow.inner.begin_function_eval::<FastRng>(
290        program,
291        line_tables,
292        &mut view,
293        &handler,
294        idx,
295        args,
296        None,
297    )?;
298    triggers.extend(handler.take_queued());
299    classify_eval(&flow.inner, program, &bindings, outcome)
300}
301
302/// Apply a batch of engine→ink calls to one flow in a **single** VM-eval
303/// setup, returning one [`Result`] per call in the order supplied.
304///
305/// This is the batch counterpart of [`call_ink_function`]: an engine→ink seam
306/// (e.g. an event-folding system that pushes a frame's events into ink) can
307/// hand the whole frame's calls over at once. The expensive per-call setup —
308/// building the [`SystemState`] over the flow components + assets + bindings —
309/// is paid **once** for the batch instead of once per call, so a frame with
310/// N sightings costs one setup plus N evaluations rather than N setups.
311///
312/// Per-call semantics are preserved exactly:
313///
314/// - **Order.** Calls run front-to-back, so `decay` then N × `escalate` then
315///   `trigger_global` fold in the same order as N separate
316///   [`call_ink_function`]s. State mutated by an earlier call is visible to a
317///   later one (they share the flow's globals/context).
318/// - **Isolated outputs.** Each call is a fresh `begin_function_eval` — output
319///   is isolated, the player-visible story is untouched, visit counts aren't
320///   bumped — identical to a standalone [`call_ink_function`].
321/// - **Error per call, no silent drops.** A failing call yields `Err(_)` in
322///   *its* slot and does **not** abort the batch; every later call still runs
323///   and every slot is reported. The returned `Vec` has exactly one entry per
324///   input call.
325///
326/// The result of the *i*-th call is `results[i]`. `M` is the story marker (use
327/// `()` for the default). For callers that don't have `&mut World` (a normal
328/// system), issue a deferred `commands.brink_call_batch(...)` instead.
329///
330/// ```no_run
331/// # use bevy_ecs::entity::Entity;
332/// # use bevy_ecs::world::World;
333/// # use bevy_log::warn;
334/// # use bevy_brink::{Value, call_ink_functions};
335/// # struct AlarmStory;
336/// # fn example(
337/// #     world: &mut World,
338/// #     flow: Entity,
339/// #     round_started: bool,
340/// #     dt: f32,
341/// #     spots: Vec<f32>,
342/// #     has_global: bool,
343/// # ) {
344/// // The alarm write-seam, one VM entry instead of N+2:
345/// let mut calls: Vec<(&str, Vec<Value>)> = Vec::new();
346/// if round_started { calls.push(("alarm_reset", vec![])); }
347/// calls.push(("decay", vec![Value::Float(dt)]));
348/// for amount in spots { calls.push(("escalate_spotting", vec![Value::Float(amount)])); }
349/// if has_global { calls.push(("trigger_global", vec![])); }
350/// for (call, res) in calls.iter().zip(call_ink_functions::<AlarmStory, _, _>(world, flow, calls.clone())) {
351///     if let Err(err) = res { warn!("[alarm] ink call {} failed: {err}", call.0); }
352/// }
353/// # }
354/// ```
355///
356/// # Errors
357/// Errors are returned per call in the result `Vec`; the function itself does
358/// not short-circuit. See [`BrinkCallError`].
359pub fn call_ink_functions<M, N, A>(
360    world: &mut World,
361    entity: Entity,
362    calls: impl IntoIterator<Item = (N, A)>,
363) -> Vec<Result<Value, BrinkCallError>>
364where
365    M: Send + Sync + 'static,
366    N: AsRef<str>,
367    A: AsRef<[Value]>,
368{
369    // One setup for the whole batch — the amortization the batch exists for.
370    let mut state: EvalSystemState<M> = SystemState::new(world);
371
372    calls
373        .into_iter()
374        .map(|(name, args)| {
375            // Fresh trigger buffer per call so a command-event fires right
376            // after *its own* call completes — preserving the "isolated
377            // outputs, identical to a standalone call_ink_function" contract
378            // this batch API documents, rather than deferring every call's
379            // triggers to the end of the whole batch.
380            let mut triggers: Vec<TriggerFn> = Vec::new();
381            let next = begin_eval_by_name(
382                world,
383                entity,
384                &mut state,
385                name.as_ref(),
386                args.as_ref(),
387                &mut triggers,
388            )?;
389            let value =
390                drive_function_eval_to_done(world, entity, &mut state, next, &mut triggers)?;
391            flush_eval_triggers(world, triggers);
392            Ok(value)
393        })
394        .collect()
395}
396
397/// Synchronously invoke an ink **function value** (`#fn(…)` — a `FnRef` or
398/// `Closure`) on a flow entity from an exclusive (`&mut World`) context,
399/// returning its value — the host callback-invocation surface (T1c-3,
400/// `docs/t1c-spec.md` §6).
401///
402/// This is the [`call_ink_function`] sibling for the case where the host
403/// holds an opaque function-value token (obtained from a global, a returned
404/// value, or a `bind_brink_query` result) rather than a static function name.
405/// The host never dereferences the token's env — invocation re-enters the VM
406/// (`FlowInstance::begin_function_value_eval`), running any world-access query
407/// bindings the callback triggers, and is journaled exactly like a by-name
408/// call. `args` supply the remaining (val-only) params after the value's bound
409/// prefix.
410///
411/// The dispatch faults of `docs/t1c-spec.md` §3/§6 (non-function value, wrong
412/// arity, rehydration mismatch, cross-flow ref-`#@local`) surface as
413/// [`BrinkCallError::Runtime`].
414///
415/// `M` is the story marker (use `()` for the default).
416///
417/// # Errors
418/// See [`BrinkCallError`].
419pub fn call_ink_function_value<M: Send + Sync + 'static>(
420    world: &mut World,
421    entity: Entity,
422    callee: &Value,
423    args: &[Value],
424) -> Result<Value, BrinkCallError> {
425    let mut state: EvalSystemState<M> = SystemState::new(world);
426    let mut triggers: Vec<TriggerFn> = Vec::new();
427
428    // Begin the evaluation through the opaque function-value token.
429    let next = {
430        let (mut flows, globals, programs, tables, bindings) = state
431            .get_mut(world)
432            .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
433        let mut globals = globals.ok_or(BrinkCallError::NotAFlow)?;
434        let (prog_c, loc_c, mut flow, mut ctx) = flows
435            .get_mut(entity)
436            .map_err(|_| BrinkCallError::NotAFlow)?;
437        let program = &programs
438            .get(&prog_c.handle)
439            .ok_or(BrinkCallError::ProgramNotLoaded)?
440            .program;
441        let line_tables = &tables
442            .get(&loc_c.handle)
443            .ok_or(BrinkCallError::LineTablesNotLoaded)?
444            .tables;
445        let handler = bindings.eval_handler();
446        let mut view = crate::globals::flow_context_view(&mut globals, &mut ctx);
447        let outcome = flow.inner.begin_function_value_eval::<FastRng>(
448            program,
449            line_tables,
450            &mut view,
451            &handler,
452            callee,
453            args,
454            None,
455        )?;
456        triggers.extend(handler.take_queued());
457        classify_eval(&flow.inner, program, &bindings, outcome)?
458    };
459
460    let value = drive_function_eval_to_done(world, entity, &mut state, next, &mut triggers)?;
461    flush_eval_triggers(world, triggers);
462    Ok(value)
463}
464
465/// One step of the [`advance_flow`] loop, captured inside the borrow scope
466/// so the World can be re-borrowed (for `run_system_with`) afterward.
467enum FlowStep {
468    /// A line was produced.
469    Line(Step),
470    /// The flow paused on a world-access query; run this system then resume.
471    Query {
472        system: QuerySystemId,
473        qargs: Vec<Value>,
474        /// External name, carried only in dev builds so the resolved query
475        /// result can be recorded into the flow's replay log.
476        #[cfg(feature = "dev")]
477        name: String,
478    },
479}
480
481/// Fire the per-line observer event (matching [`step_one`](crate::BrinkFlow::step_one))
482/// from an exclusive `&mut World` context.
483///
484/// Terminals carry no payload of their own (`docs/prose-dialect-spec.md`
485/// §7, RULED) — `BrinkTurnDone`/`BrinkStoryEnded` always fire with empty
486/// `text`/`tags`; any trailing content already arrived as its own
487/// preceding `BrinkLineDelivered` event.
488fn emit_line_event_world<M: Send + Sync + 'static>(world: &mut World, entity: Entity, step: &Step) {
489    use crate::event::{BrinkChoicesPresented, BrinkLineDelivered, BrinkStoryEnded, BrinkTurnDone};
490    match step {
491        Step::Line(line) => {
492            world
493                .entity_mut(entity)
494                .trigger(|e| BrinkLineDelivered::<M>::new(e, line.text.clone(), line.tags.clone()));
495        }
496        Step::Choices(choices) => {
497            world.entity_mut(entity).trigger(|e| {
498                BrinkChoicesPresented::<M>::new(e, String::new(), Vec::new(), choices.clone())
499            });
500        }
501        // A park (`Step::Suspended`, FS-3r) is a turn boundary like `Done`;
502        // runtime-unreachable today behind the E052 fence, grouped here so
503        // the exhaustive match keeps compiling as the variant lands.
504        Step::Done | Step::Suspended => {
505            world
506                .entity_mut(entity)
507                .trigger(|e| BrinkTurnDone::<M>::new(e, String::new(), Vec::new()));
508        }
509        Step::End => {
510            world
511                .entity_mut(entity)
512                .trigger(|e| BrinkStoryEnded::<M>::new(e, String::new(), Vec::new()));
513        }
514    }
515}
516
517/// One `advance` step for [`advance_flow`], wrapping the handler with a
518/// [`RecordingHandler`] when a recorder is active (dev) so inline pure/command
519/// results are captured into the flow's replay log. A thin pass-through in
520/// non-dev builds (the `recorder` parameter doesn't exist there).
521fn advance_recording<M: Send + Sync + 'static>(
522    flow: &mut FlowInstance,
523    program: &Program,
524    line_tables: &[Vec<brink_format::LineEntry>],
525    context: &mut (impl brink_runtime::ContextAccess + ?Sized),
526    handler: &BrinkHandler<'_, M>,
527    #[cfg(feature = "dev")] recorder: Option<&mut ReplayRecorder>,
528) -> Result<StepOutcome, RuntimeError> {
529    #[cfg(feature = "dev")]
530    if let Some(rec) = recorder {
531        let recording = RecordingHandler::new(handler, rec);
532        return flow.advance::<FastRng>(program, line_tables, context, &recording, None);
533    }
534    flow.advance::<FastRng>(program, line_tables, context, handler, None)
535}
536
537/// Advance a flow by one line from an exclusive (`&mut World`) context,
538/// resolving any world-access query bindings inline via `run_system_with`.
539///
540/// This is the playback counterpart to [`call_ink_function`]: where a
541/// non-exclusive `step_one` can only resolve pure/command bindings (query
542/// bindings fall back), `advance_flow` runs the query binding's system
543/// between the runtime's eval suspensions — so a story line like
544/// `{enemy_count()}` resolves transparently in one frame. Buffered command
545/// events are flushed and the line's observer event is fired, exactly as
546/// `step_one` would.
547///
548/// Bounded by a [`FlowInstance::LINE_LIMIT`] budget shared across every
549/// inline resume this call makes (each pending query resolved and each line
550/// produced decrements it by one) — reconciled onto the same
551/// `RuntimeError::LineLimitExceeded` convention
552/// [`FlowInstance::drive`]/`advance_until_terminal` use, rather than
553/// looping unboundedly if a story keeps calling inline-resolvable externals
554/// without ever producing a line (guard against unbounded growth).
555///
556/// # Errors
557/// See [`BrinkCallError`].
558#[expect(
559    clippy::too_many_lines,
560    reason = "the SystemState re-borrow dance around run_system_with doesn't split cleanly"
561)]
562pub fn advance_flow<M: Send + Sync + 'static>(
563    world: &mut World,
564    entity: Entity,
565) -> Result<Step, BrinkCallError> {
566    #[expect(
567        clippy::type_complexity,
568        reason = "SystemState param tuple for the flow components + assets + bindings"
569    )]
570    let mut state: SystemState<(
571        Query<(
572            &BrinkProgram<M>,
573            &BrinkLocale<M>,
574            &mut BrinkFlow<M>,
575            &mut BrinkContext<M>,
576        )>,
577        Option<ResMut<crate::BrinkGlobals<M>>>,
578        Res<Assets<ProgramAsset>>,
579        Res<Assets<LineTablesAsset>>,
580        Res<BrinkBindings<M>>,
581    )> = SystemState::new(world);
582
583    // Command-event triggers accumulate across the suspensions of a single
584    // line, then flush once the line is produced.
585    let mut triggers: Vec<TriggerFn> = Vec::new();
586
587    // In dev builds, record every external resolved during this pass into the
588    // flow's replay log so a hot-reload can replay it faithfully. Taken out of
589    // the component up front (and put back before the line returns) so it can
590    // wrap the handler / be written without holding `BrinkReplayLog` borrowed
591    // across the World re-borrows below. `None` for a non-dev-tracked flow.
592    #[cfg(feature = "dev")]
593    let mut recorder: Option<ReplayRecorder> = crate::replay::take_recorder::<M>(world, entity);
594
595    let mut budget = FlowInstance::LINE_LIMIT;
596
597    loop {
598        if budget == 0 {
599            return Err(RuntimeError::LineLimitExceeded(FlowInstance::LINE_LIMIT).into());
600        }
601        budget -= 1;
602
603        let step = {
604            let (mut flows, globals, programs, tables, bindings) = state
605                .get_mut(world)
606                .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
607            let mut globals = globals.ok_or(BrinkCallError::NotAFlow)?;
608            let (prog_c, loc_c, mut flow, mut ctx) = flows
609                .get_mut(entity)
610                .map_err(|_| BrinkCallError::NotAFlow)?;
611            let program = &programs
612                .get(&prog_c.handle)
613                .ok_or(BrinkCallError::ProgramNotLoaded)?
614                .program;
615            let line_tables = &tables
616                .get(&loc_c.handle)
617                .ok_or(BrinkCallError::LineTablesNotLoaded)?
618                .tables;
619            let handler = bindings.handler();
620            let mut view = crate::globals::flow_context_view(&mut globals, &mut ctx);
621            // Inline pure/command results are captured by `advance_recording`'s
622            // RecordingHandler wrap (dev); out-of-band query results are recorded
623            // at the resolve site below.
624            let outcome = advance_recording(
625                &mut flow.inner,
626                program,
627                line_tables,
628                &mut view,
629                &handler,
630                #[cfg(feature = "dev")]
631                recorder.as_mut(),
632            )?;
633            triggers.extend(handler.take_queued());
634            match outcome {
635                StepOutcome::Step(step) => FlowStep::Line(step),
636                StepOutcome::AwaitingExternal => {
637                    let name = flow
638                        .inner
639                        .pending_external_name(program)
640                        .unwrap_or_default()
641                        .to_owned();
642                    if bindings.async_bindings.contains_key(&name) {
643                        return Err(BrinkCallError::AsyncExternalUnsupported(name));
644                    }
645                    let system = bindings
646                        .query(&name)
647                        .ok_or_else(|| BrinkCallError::UnknownQuery(name.clone()))?;
648                    let qargs = flow.inner.pending_external_args().to_vec();
649                    FlowStep::Query {
650                        system,
651                        qargs,
652                        #[cfg(feature = "dev")]
653                        name,
654                    }
655                }
656            }
657        };
658
659        match step {
660            FlowStep::Line(line) => {
661                for trigger in triggers {
662                    trigger(world);
663                }
664                emit_line_event_world::<M>(world, entity, &line);
665                #[cfg(feature = "dev")]
666                if let Some(rec) = recorder {
667                    crate::replay::put_recorder::<M>(world, entity, rec);
668                }
669                return Ok(line);
670            }
671            FlowStep::Query {
672                system,
673                qargs,
674                #[cfg(feature = "dev")]
675                name,
676            } => {
677                let value = world
678                    .run_system_with(system, (entity, qargs.clone()))
679                    .map_err(|e| BrinkCallError::QueryFailed(format!("{e:?}")))?;
680                #[cfg(feature = "dev")]
681                if let Some(rec) = recorder.as_mut() {
682                    rec.record(&name, &qargs, &value);
683                }
684                let (mut flows, ..) = state
685                    .get_mut(world)
686                    .map_err(|e| BrinkCallError::SystemParamInvalid(e.to_string()))?;
687                let (_, _, mut flow, _) = flows
688                    .get_mut(entity)
689                    .map_err(|_| BrinkCallError::NotAFlow)?;
690                flow.inner.resolve_external(value);
691            }
692        }
693    }
694}
695
696/// What [`dispatch_one_external`] decided to do for a parked flow, computed
697/// inside the immutable borrow scope and acted on afterward (each variant
698/// needs `&mut World`).
699enum Dispatch {
700    /// Nothing to do (no pending external, program not loaded yet, already
701    /// dispatched, or genuinely unbound — the latter warns inside).
702    Nothing,
703    /// World-access query: run the system, resolve with its return value.
704    Query {
705        system: QuerySystemId,
706        qargs: Vec<Value>,
707        /// External name, carried in dev builds so the resolved query result
708        /// can be recorded into the flow's replay log, and in `effect-trace`
709        /// builds so the dispatch can be logged against the binding's
710        /// captured `Access` (issue #938's ground-truth check).
711        #[cfg(any(feature = "dev", feature = "effect-trace"))]
712        name: String,
713    },
714    /// `bind_brink_async` (event): fire [`BrinkExternalAwaited`] + insert the
715    /// [`BrinkAwaiting`] marker.
716    FireEvent { name: String, qargs: Vec<Value> },
717    /// `bind_brink_task`: spawn this future on the async pool, park a
718    /// [`BrinkPendingTask`].
719    SpawnTask {
720        fut: Pin<Box<dyn Future<Output = Value> + Send>>,
721        /// Name + args, carried only in dev builds so [`poll_brink_tasks`]
722        /// can record the task's result into the flow's replay log when it
723        /// completes (the value isn't known until then).
724        #[cfg(feature = "dev")]
725        name: String,
726        #[cfg(feature = "dev")]
727        qargs: Vec<Value>,
728    },
729}
730
731/// Resolve / hand off the (single) external a parked flow is waiting on,
732/// dispatched by binding kind:
733/// - world-access query → run its system inline and resolve;
734/// - `bind_brink_async` → fire [`BrinkExternalAwaited`] once (guarded by the
735///   [`BrinkAwaiting`] marker) and leave the flow parked for the engine;
736/// - `bind_brink_task` → spawn the future once (guarded by [`BrinkPendingTask`])
737///   and leave the flow parked for [`poll_brink_tasks`](crate::poll_brink_tasks).
738///
739/// Decide what [`dispatch_one_external`] should do for `entity`, computed inside
740/// the immutable borrow scope (so the action can re-borrow `&mut World`).
741/// [`Dispatch::Nothing`] when the flow has no pending external, isn't loaded, or
742/// has already been dispatched.
743#[expect(
744    clippy::type_complexity,
745    reason = "SystemState param tuple for the flow component (+ dispatch markers) + assets + bindings"
746)]
747fn decide_dispatch<M: Send + Sync + 'static>(world: &mut World, entity: Entity) -> Dispatch {
748    let mut state: SystemState<(
749        Query<(
750            &BrinkProgram<M>,
751            &BrinkFlow<M>,
752            Option<&BrinkAwaiting<M>>,
753            Option<&BrinkPendingTask<M>>,
754        )>,
755        Res<Assets<ProgramAsset>>,
756        Res<BrinkBindings<M>>,
757    )> = SystemState::new(world);
758    let Ok((flows, programs, bindings)) = state.get(world) else {
759        // A required resource (Assets<ProgramAsset>, BrinkBindings<M>) isn't
760        // present yet — leave parked; we'll retry next frame.
761        return Dispatch::Nothing;
762    };
763    let Ok((prog_c, flow, awaiting, pending_task)) = flows.get(entity) else {
764        return Dispatch::Nothing;
765    };
766    if !flow.inner.has_pending_external() {
767        return Dispatch::Nothing;
768    }
769    let Some(program) = programs.get(&prog_c.handle) else {
770        // Program not loaded yet — leave parked; we'll retry next frame.
771        return Dispatch::Nothing;
772    };
773    let program = &program.program;
774    let name = flow
775        .inner
776        .pending_external_name(program)
777        .unwrap_or_default()
778        .to_owned();
779    let qargs = flow.inner.pending_external_args().to_vec();
780
781    if let Some(system) = bindings.query(&name) {
782        Dispatch::Query {
783            system,
784            qargs,
785            #[cfg(any(feature = "dev", feature = "effect-trace"))]
786            name,
787        }
788    } else if let Some(kind) = bindings.async_bindings.get(&name) {
789        match kind {
790            AsyncKind::Event if awaiting.is_some() => Dispatch::Nothing, // already fired
791            AsyncKind::Event => Dispatch::FireEvent { name, qargs },
792            AsyncKind::Task(_) if pending_task.is_some() => Dispatch::Nothing, // already spawned
793            AsyncKind::Task(factory) => {
794                #[cfg(feature = "dev")]
795                let fut = factory(qargs.clone());
796                #[cfg(not(feature = "dev"))]
797                let fut = factory(qargs);
798                Dispatch::SpawnTask {
799                    fut,
800                    #[cfg(feature = "dev")]
801                    name,
802                    #[cfg(feature = "dev")]
803                    qargs,
804                }
805            }
806        }
807    } else {
808        // Pending but unbound. The handler only pauses on registered names, so
809        // this indicates a registration race; warn and leave parked.
810        warn!("brink: flow {entity:?} parked on unbound external '{name}'");
811        Dispatch::Nothing
812    }
813}
814
815/// A no-op when the flow has no pending external or its program isn't loaded.
816fn dispatch_one_external<M: Send + Sync + 'static>(world: &mut World, entity: Entity) {
817    match decide_dispatch::<M>(world, entity) {
818        Dispatch::Nothing => {}
819        Dispatch::Query {
820            system,
821            qargs,
822            #[cfg(any(feature = "dev", feature = "effect-trace"))]
823            name,
824        } => match world.run_system_with(system, (entity, qargs.clone())) {
825            Ok(value) => {
826                #[cfg(feature = "dev")]
827                crate::replay::record_external::<M>(world, entity, &name, &qargs, &value);
828                // Issue #938's host-side ground-truth check: log this real
829                // dispatch's binding-declared `Access` (captured at
830                // `bind_brink_query` registration) so a test/harness can
831                // later assert it stayed a subset of BH-1's row-join
832                // (`crate::ground_truth::check`). A missing `BrinkBindings<M>`
833                // resource or an unregistered binding name (should not
834                // happen — this dispatch only runs for a name `bindings.query`
835                // just resolved) skips recording rather than panicking.
836                #[cfg(feature = "effect-trace")]
837                if let Some(access) = world
838                    .get_resource::<BrinkBindings<M>>()
839                    .and_then(|b| b.query_access(&name))
840                    .cloned()
841                {
842                    crate::ground_truth::record::<M>(world, entity, &name, access);
843                }
844                let mut flows = world.query::<&mut BrinkFlow<M>>();
845                if let Ok(mut flow) = flows.get_mut(world, entity) {
846                    flow.inner.resolve_external(value);
847                }
848            }
849            Err(err) => warn!("brink: query binding failed on {entity:?}: {err:?}"),
850        },
851        Dispatch::FireEvent { name, qargs } => {
852            // Insert the marker BEFORE firing so a synchronous resolve observer
853            // can find + remove it (world.trigger runs observers and flushes
854            // their commands inline).
855            world
856                .entity_mut(entity)
857                .insert(BrinkAwaiting::<M>::new(name.clone()));
858            world
859                .entity_mut(entity)
860                .trigger(|e| BrinkExternalAwaited::<M>::new(e, name, qargs));
861        }
862        Dispatch::SpawnTask {
863            fut,
864            #[cfg(feature = "dev")]
865            name,
866            #[cfg(feature = "dev")]
867            qargs,
868        } => {
869            // get_or_init so we don't panic in apps/tests without TaskPoolPlugin;
870            // a no-op when the pool is already set up (e.g. by DefaultPlugins).
871            let task = AsyncComputeTaskPool::get_or_init(TaskPool::default).spawn(fut);
872            world.entity_mut(entity).insert(BrinkPendingTask::<M>::new(
873                task,
874                #[cfg(feature = "dev")]
875                name,
876                #[cfg(feature = "dev")]
877                qargs,
878            ));
879        }
880    }
881}
882
883/// Run condition: `true` if any `BrinkFlow<M>` is paused on a pending
884/// external (so the resolver only runs when there's work).
885#[must_use]
886pub fn any_flow_awaiting_external<M: Send + Sync + 'static>(flows: Query<&BrinkFlow<M>>) -> bool {
887    flows.iter().any(|f| f.inner.has_pending_external())
888}
889
890/// Exclusive plugin system: service flows that paused on a pending external
891/// during normal playback (after a non-exclusive
892/// [`step_one`](crate::BrinkFlow::step_one) yielded
893/// [`Advance::AwaitingQuery`](crate::Advance::AwaitingQuery)).
894///
895/// For each parked flow, [`dispatch_one_external`] resolves a world-access
896/// query inline, fires [`BrinkExternalAwaited`] for a `bind_brink_async`
897/// binding, or spawns the task for a `bind_brink_task` binding. Registered by
898/// the plugin, gated on [`any_flow_awaiting_external`].
899pub fn resolve_pending_externals<M: Send + Sync + 'static>(world: &mut World) {
900    let paused: Vec<Entity> = {
901        let mut flows = world.query::<(Entity, &BrinkFlow<M>)>();
902        flows
903            .iter(world)
904            .filter(|(_, f)| f.inner.has_pending_external())
905            .map(|(e, _)| e)
906            .collect()
907    };
908    for entity in paused {
909        dispatch_one_external::<M>(world, entity);
910    }
911}