use std::cell::RefCell;
use std::collections::HashMap;
use std::future::Future;
use std::marker::PhantomData;
use std::pin::Pin;
use bevy_app::App;
use bevy_asset::Assets;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::Event;
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{Commands, In, IntoSystem, Query, Res, SystemId, SystemState};
use bevy_ecs::world::World;
use bevy_log::warn;
use bevy_tasks::{AsyncComputeTaskPool, TaskPool};
use brink_format::Value;
use brink_runtime::{
ExternalFnHandler, ExternalResult, FastRng, FlowInstance, Line, Program, RuntimeError,
StepOutcome,
};
use thiserror::Error;
use crate::asset::{BrinkProgram, LineTablesAsset, ProgramAsset};
use crate::async_bind::{BrinkAwaiting, BrinkExternalAwaited, BrinkPendingTask};
use crate::flow::BrinkFlow;
use crate::globals::BrinkContext;
use crate::line_tables::BrinkLocale;
pub type BrinkQueryInput = (Entity, Vec<Value>);
type QuerySystemId = SystemId<In<BrinkQueryInput>, Value>;
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum BrinkArgError {
#[error("expected {expected} argument(s), got {got}")]
Count {
expected: usize,
got: usize,
},
#[error("argument {index}: expected {expected}")]
Type {
index: usize,
expected: &'static str,
},
}
pub trait BrinkCommand: Sized {
fn from_ink_args(args: &[Value]) -> Result<Self, BrinkArgError>;
fn reply(&self) -> Value {
Value::Null
}
}
type PureFn = Box<dyn Fn(&[Value]) -> Value + Send + Sync>;
type CommandFn = Box<dyn Fn(&[Value]) -> Result<QueuedCommand, BrinkArgError> + Send + Sync>;
type TaskFn = Box<dyn Fn(Vec<Value>) -> Pin<Box<dyn Future<Output = Value> + Send>> + Send + Sync>;
enum AsyncKind {
Event,
Task(TaskFn),
}
type TriggerFn = Box<dyn FnOnce(&mut World) + Send>;
struct QueuedCommand {
trigger: TriggerFn,
reply: Value,
}
#[derive(Resource)]
pub struct BrinkBindings<M: Send + Sync + 'static = ()> {
pure: HashMap<String, PureFn>,
commands: HashMap<String, CommandFn>,
queries: HashMap<String, QuerySystemId>,
async_bindings: HashMap<String, AsyncKind>,
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> Default for BrinkBindings<M> {
fn default() -> Self {
Self {
pure: HashMap::new(),
commands: HashMap::new(),
queries: HashMap::new(),
async_bindings: HashMap::new(),
_marker: PhantomData,
}
}
}
impl<M: Send + Sync + 'static> BrinkBindings<M> {
#[must_use]
pub fn handler(&self) -> BrinkHandler<'_, M> {
BrinkHandler {
bindings: self,
queued: RefCell::new(Vec::new()),
}
}
fn query(&self, name: &str) -> Option<QuerySystemId> {
self.queries.get(name).copied()
}
}
pub struct BrinkHandler<'a, M: Send + Sync + 'static = ()> {
bindings: &'a BrinkBindings<M>,
queued: RefCell<Vec<TriggerFn>>,
}
impl<M: Send + Sync + 'static> BrinkHandler<'_, M> {
pub fn flush(self, commands: &mut Commands) {
for trigger in self.queued.into_inner() {
commands.queue(trigger);
}
}
fn take_queued(&self) -> Vec<TriggerFn> {
std::mem::take(&mut self.queued.borrow_mut())
}
#[must_use]
pub fn queued_len(&self) -> usize {
self.queued.borrow().len()
}
}
impl<M: Send + Sync + 'static> ExternalFnHandler for BrinkHandler<'_, M> {
fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
if let Some(f) = self.bindings.pure.get(name) {
return ExternalResult::Resolved(f(args));
}
if let Some(parse) = self.bindings.commands.get(name) {
return match parse(args) {
Ok(queued) => {
self.queued.borrow_mut().push(queued.trigger);
ExternalResult::Resolved(queued.reply)
}
Err(err) => {
warn!("brink command '{name}': {err}; emitting nothing, returning null");
ExternalResult::Resolved(Value::Null)
}
};
}
if self.bindings.queries.contains_key(name)
|| self.bindings.async_bindings.contains_key(name)
{
return ExternalResult::Pending;
}
ExternalResult::Fallback
}
}
impl<M: Send + Sync + 'static> BrinkBindings<M> {
fn eval_handler(&self) -> EvalHandler<'_, M> {
EvalHandler { bindings: self }
}
}
struct EvalHandler<'a, M: Send + Sync + 'static> {
bindings: &'a BrinkBindings<M>,
}
impl<M: Send + Sync + 'static> ExternalFnHandler for EvalHandler<'_, M> {
fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
if let Some(f) = self.bindings.pure.get(name) {
return ExternalResult::Resolved(f(args));
}
if self.bindings.queries.contains_key(name)
|| self.bindings.async_bindings.contains_key(name)
{
return ExternalResult::Pending;
}
ExternalResult::Fallback
}
}
pub trait BrinkBindingsAppExt {
fn bind_brink_fn<M, F, R>(&mut self, name: impl Into<String>, f: F) -> &mut Self
where
M: Send + Sync + 'static,
F: Fn(&[Value]) -> R + Send + Sync + 'static,
R: Into<Value>;
fn bind_brink_command<M, E>(&mut self, name: impl Into<String>) -> &mut Self
where
M: Send + Sync + 'static,
E: Event + BrinkCommand,
for<'a> <E as Event>::Trigger<'a>: Default;
fn bind_brink_query<M, S, SM>(&mut self, name: impl Into<String>, system: S) -> &mut Self
where
M: Send + Sync + 'static,
S: IntoSystem<In<BrinkQueryInput>, Value, SM> + 'static;
fn bind_brink_async<M>(&mut self, name: impl Into<String>) -> &mut Self
where
M: Send + Sync + 'static;
fn bind_brink_task<M, F, Fut>(&mut self, name: impl Into<String>, f: F) -> &mut Self
where
M: Send + Sync + 'static,
F: Fn(Vec<Value>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Value> + Send + 'static;
}
impl BrinkBindingsAppExt for App {
fn bind_brink_fn<M, F, R>(&mut self, name: impl Into<String>, f: F) -> &mut Self
where
M: Send + Sync + 'static,
F: Fn(&[Value]) -> R + Send + Sync + 'static,
R: Into<Value>,
{
let name = name.into();
{
let mut reg = self
.world_mut()
.get_resource_or_insert_with(BrinkBindings::<M>::default);
reg.pure.insert(name, Box::new(move |args| f(args).into()));
}
self
}
fn bind_brink_command<M, E>(&mut self, name: impl Into<String>) -> &mut Self
where
M: Send + Sync + 'static,
E: Event + BrinkCommand,
for<'a> <E as Event>::Trigger<'a>: Default,
{
let name = name.into();
{
let mut reg = self
.world_mut()
.get_resource_or_insert_with(BrinkBindings::<M>::default);
reg.commands.insert(
name,
Box::new(move |args: &[Value]| {
let event = E::from_ink_args(args)?;
let reply = event.reply();
Ok(QueuedCommand {
trigger: Box::new(move |world: &mut World| {
world.trigger(event);
}),
reply,
})
}),
);
}
self
}
fn bind_brink_query<M, S, SM>(&mut self, name: impl Into<String>, system: S) -> &mut Self
where
M: Send + Sync + 'static,
S: IntoSystem<In<BrinkQueryInput>, Value, SM> + 'static,
{
let name = name.into();
let id = self.world_mut().register_system(system);
{
let mut reg = self
.world_mut()
.get_resource_or_insert_with(BrinkBindings::<M>::default);
reg.queries.insert(name, id);
}
self
}
fn bind_brink_async<M>(&mut self, name: impl Into<String>) -> &mut Self
where
M: Send + Sync + 'static,
{
let name = name.into();
{
let mut reg = self
.world_mut()
.get_resource_or_insert_with(BrinkBindings::<M>::default);
reg.async_bindings.insert(name, AsyncKind::Event);
}
self
}
fn bind_brink_task<M, F, Fut>(&mut self, name: impl Into<String>, f: F) -> &mut Self
where
M: Send + Sync + 'static,
F: Fn(Vec<Value>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Value> + Send + 'static,
{
let name = name.into();
let factory: TaskFn = Box::new(move |args| Box::pin(f(args)));
{
let mut reg = self
.world_mut()
.get_resource_or_insert_with(BrinkBindings::<M>::default);
reg.async_bindings.insert(name, AsyncKind::Task(factory));
}
self
}
}
#[derive(Debug, Error)]
pub enum BrinkCallError {
#[error("entity is not a fulfilled brink flow")]
NotAFlow,
#[error("program asset not loaded")]
ProgramNotLoaded,
#[error("line tables asset not loaded")]
LineTablesNotLoaded,
#[error("function '{0}' not found")]
FunctionNotFound(String),
#[error("no query binding registered for external '{0}'")]
UnknownQuery(String),
#[error("external '{0}' is async; drive the flow via step_one, not the exclusive driver")]
AsyncExternalUnsupported(String),
#[error("query binding system failed: {0}")]
QueryFailed(String),
#[error(transparent)]
Runtime(#[from] RuntimeError),
}
enum NextStep {
Done(Value),
RunQuery {
system: QuerySystemId,
qargs: Vec<Value>,
},
}
fn classify_eval<M: Send + Sync + 'static>(
flow: &FlowInstance,
program: &Program,
bindings: &BrinkBindings<M>,
outcome: brink_runtime::FunctionEval,
) -> Result<NextStep, BrinkCallError> {
match outcome {
brink_runtime::FunctionEval::Returned(value) => Ok(NextStep::Done(value)),
brink_runtime::FunctionEval::AwaitingExternal => {
let name = flow
.pending_external_name(program)
.unwrap_or_default()
.to_owned();
if bindings.async_bindings.contains_key(&name) {
return Err(BrinkCallError::AsyncExternalUnsupported(name));
}
let system = bindings
.query(&name)
.ok_or(BrinkCallError::UnknownQuery(name))?;
let qargs = flow.pending_external_args().to_vec();
Ok(NextStep::RunQuery { system, qargs })
}
}
}
pub fn call_ink_function<M: Send + Sync + 'static>(
world: &mut World,
entity: Entity,
name: &str,
args: &[Value],
) -> Result<Value, BrinkCallError> {
#[expect(
clippy::type_complexity,
reason = "SystemState param tuple for the flow components + assets + bindings"
)]
let mut state: SystemState<(
Query<(
&BrinkProgram<M>,
&BrinkLocale<M>,
&mut BrinkFlow<M>,
&mut BrinkContext<M>,
)>,
Res<Assets<ProgramAsset>>,
Res<Assets<LineTablesAsset>>,
Res<BrinkBindings<M>>,
)> = SystemState::new(world);
let mut next = {
let (mut flows, programs, tables, bindings) = state.get_mut(world);
let (prog_c, loc_c, mut flow, mut ctx) = flows
.get_mut(entity)
.map_err(|_| BrinkCallError::NotAFlow)?;
let program = &programs
.get(&prog_c.handle)
.ok_or(BrinkCallError::ProgramNotLoaded)?
.program;
let line_tables = &tables
.get(&loc_c.handle)
.ok_or(BrinkCallError::LineTablesNotLoaded)?
.tables;
let idx = program
.find_address(name)
.ok_or_else(|| BrinkCallError::FunctionNotFound(name.to_owned()))?
.0;
let handler = bindings.eval_handler();
let outcome = flow.inner.begin_function_eval::<FastRng>(
program,
line_tables,
&mut ctx.inner,
&handler,
idx,
args,
None,
)?;
classify_eval(&flow.inner, program, &bindings, outcome)?
};
loop {
match next {
NextStep::Done(value) => return Ok(value),
NextStep::RunQuery { system, qargs } => {
let value = world
.run_system_with(system, (entity, qargs))
.map_err(|e| BrinkCallError::QueryFailed(format!("{e:?}")))?;
next = {
let (mut flows, programs, tables, bindings) = state.get_mut(world);
let (prog_c, loc_c, mut flow, mut ctx) = flows
.get_mut(entity)
.map_err(|_| BrinkCallError::NotAFlow)?;
let program = &programs
.get(&prog_c.handle)
.ok_or(BrinkCallError::ProgramNotLoaded)?
.program;
let line_tables = &tables
.get(&loc_c.handle)
.ok_or(BrinkCallError::LineTablesNotLoaded)?
.tables;
let handler = bindings.eval_handler();
flow.inner.resolve_external(value);
let outcome = flow.inner.resume_function_eval::<FastRng>(
program,
line_tables,
&mut ctx.inner,
&handler,
None,
)?;
classify_eval(&flow.inner, program, &bindings, outcome)?
};
}
}
}
}
enum FlowStep {
Line(Line),
Query {
system: QuerySystemId,
qargs: Vec<Value>,
},
}
fn emit_line_event_world<M: Send + Sync + 'static>(world: &mut World, entity: Entity, line: &Line) {
use crate::event::{BrinkChoicesPresented, BrinkLineDelivered, BrinkStoryEnded, BrinkTurnDone};
match line {
Line::Text { text, tags } => {
world
.entity_mut(entity)
.trigger(|e| BrinkLineDelivered::<M>::new(e, text.clone(), tags.clone()));
}
Line::Choices {
text,
tags,
choices,
} => {
world.entity_mut(entity).trigger(|e| {
BrinkChoicesPresented::<M>::new(e, text.clone(), tags.clone(), choices.clone())
});
}
Line::Done { text, tags } => {
world
.entity_mut(entity)
.trigger(|e| BrinkTurnDone::<M>::new(e, text.clone(), tags.clone()));
}
Line::End { text, tags } => {
world
.entity_mut(entity)
.trigger(|e| BrinkStoryEnded::<M>::new(e, text.clone(), tags.clone()));
}
}
}
pub fn advance_flow<M: Send + Sync + 'static>(
world: &mut World,
entity: Entity,
) -> Result<Line, BrinkCallError> {
#[expect(
clippy::type_complexity,
reason = "SystemState param tuple for the flow components + assets + bindings"
)]
let mut state: SystemState<(
Query<(
&BrinkProgram<M>,
&BrinkLocale<M>,
&mut BrinkFlow<M>,
&mut BrinkContext<M>,
)>,
Res<Assets<ProgramAsset>>,
Res<Assets<LineTablesAsset>>,
Res<BrinkBindings<M>>,
)> = SystemState::new(world);
let mut triggers: Vec<TriggerFn> = Vec::new();
loop {
let step = {
let (mut flows, programs, tables, bindings) = state.get_mut(world);
let (prog_c, loc_c, mut flow, mut ctx) = flows
.get_mut(entity)
.map_err(|_| BrinkCallError::NotAFlow)?;
let program = &programs
.get(&prog_c.handle)
.ok_or(BrinkCallError::ProgramNotLoaded)?
.program;
let line_tables = &tables
.get(&loc_c.handle)
.ok_or(BrinkCallError::LineTablesNotLoaded)?
.tables;
let handler = bindings.handler();
let outcome = flow.inner.advance::<FastRng>(
program,
line_tables,
&mut ctx.inner,
&handler,
None,
)?;
triggers.extend(handler.take_queued());
match outcome {
StepOutcome::Line(line) => FlowStep::Line(line),
StepOutcome::AwaitingExternal => {
let name = flow
.inner
.pending_external_name(program)
.unwrap_or_default()
.to_owned();
if bindings.async_bindings.contains_key(&name) {
return Err(BrinkCallError::AsyncExternalUnsupported(name));
}
let system = bindings
.query(&name)
.ok_or(BrinkCallError::UnknownQuery(name))?;
let qargs = flow.inner.pending_external_args().to_vec();
FlowStep::Query { system, qargs }
}
}
};
match step {
FlowStep::Line(line) => {
for trigger in triggers {
trigger(world);
}
emit_line_event_world::<M>(world, entity, &line);
return Ok(line);
}
FlowStep::Query { system, qargs } => {
let value = world
.run_system_with(system, (entity, qargs))
.map_err(|e| BrinkCallError::QueryFailed(format!("{e:?}")))?;
let (mut flows, ..) = state.get_mut(world);
let (_, _, mut flow, _) = flows
.get_mut(entity)
.map_err(|_| BrinkCallError::NotAFlow)?;
flow.inner.resolve_external(value);
}
}
}
}
enum Dispatch {
Nothing,
Query {
system: QuerySystemId,
qargs: Vec<Value>,
},
FireEvent { name: String, qargs: Vec<Value> },
SpawnTask {
fut: Pin<Box<dyn Future<Output = Value> + Send>>,
},
}
fn dispatch_one_external<M: Send + Sync + 'static>(world: &mut World, entity: Entity) {
#[expect(
clippy::type_complexity,
reason = "SystemState param tuple for the flow component (+ dispatch markers) + assets + bindings"
)]
let dispatch = {
let mut state: SystemState<(
Query<(
&BrinkProgram<M>,
&BrinkFlow<M>,
Option<&BrinkAwaiting<M>>,
Option<&BrinkPendingTask<M>>,
)>,
Res<Assets<ProgramAsset>>,
Res<BrinkBindings<M>>,
)> = SystemState::new(world);
let (flows, programs, bindings) = state.get(world);
let Ok((prog_c, flow, awaiting, pending_task)) = flows.get(entity) else {
return;
};
if !flow.inner.has_pending_external() {
return;
}
let Some(program) = programs.get(&prog_c.handle) else {
return;
};
let program = &program.program;
let name = flow
.inner
.pending_external_name(program)
.unwrap_or_default()
.to_owned();
let qargs = flow.inner.pending_external_args().to_vec();
if let Some(system) = bindings.query(&name) {
Dispatch::Query { system, qargs }
} else if let Some(kind) = bindings.async_bindings.get(&name) {
match kind {
AsyncKind::Event if awaiting.is_some() => Dispatch::Nothing, AsyncKind::Event => Dispatch::FireEvent { name, qargs },
AsyncKind::Task(_) if pending_task.is_some() => Dispatch::Nothing, AsyncKind::Task(factory) => Dispatch::SpawnTask {
fut: factory(qargs),
},
}
} else {
warn!("brink: flow {entity:?} parked on unbound external '{name}'");
Dispatch::Nothing
}
};
match dispatch {
Dispatch::Nothing => {}
Dispatch::Query { system, qargs } => match world.run_system_with(system, (entity, qargs)) {
Ok(value) => {
let mut flows = world.query::<&mut BrinkFlow<M>>();
if let Ok(mut flow) = flows.get_mut(world, entity) {
flow.inner.resolve_external(value);
}
}
Err(err) => warn!("brink: query binding failed on {entity:?}: {err:?}"),
},
Dispatch::FireEvent { name, qargs } => {
world
.entity_mut(entity)
.insert(BrinkAwaiting::<M>::new(name.clone()));
world
.entity_mut(entity)
.trigger(|e| BrinkExternalAwaited::<M>::new(e, name, qargs));
}
Dispatch::SpawnTask { fut } => {
let task = AsyncComputeTaskPool::get_or_init(TaskPool::default).spawn(fut);
world
.entity_mut(entity)
.insert(BrinkPendingTask::<M>::new(task));
}
}
}
#[must_use]
pub fn any_flow_awaiting_external<M: Send + Sync + 'static>(flows: Query<&BrinkFlow<M>>) -> bool {
flows.iter().any(|f| f.inner.has_pending_external())
}
pub fn resolve_pending_externals<M: Send + Sync + 'static>(world: &mut World) {
let paused: Vec<Entity> = {
let mut flows = world.query::<(Entity, &BrinkFlow<M>)>();
flows
.iter(world)
.filter(|(_, f)| f.inner.has_pending_external())
.map(|(e, _)| e)
.collect()
};
for entity in paused {
dispatch_one_external::<M>(world, entity);
}
}
#[cfg(test)]
#[expect(clippy::panic, reason = "tests assert via panic on the error arm")]
mod tests {
use super::*;
use crate::test_support::compile_test_story;
use bevy_ecs::prelude::*;
use brink_runtime::{FastRng, FlowInstance};
#[derive(Event, Clone, Debug, PartialEq, Eq)]
struct Ping {
label: String,
}
impl BrinkCommand for Ping {
fn from_ink_args(args: &[Value]) -> Result<Self, BrinkArgError> {
let label = args
.first()
.and_then(Value::as_str)
.ok_or(BrinkArgError::Type {
index: 0,
expected: "string",
})?
.to_string();
Ok(Self { label })
}
fn reply(&self) -> Value {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "test value, small"
)]
Value::Int(self.label.len() as i32)
}
}
#[derive(Event, Clone, Debug, PartialEq, bevy_brink_derive::BrinkCommand)]
struct SetVolume {
channel: i32,
level: f32,
}
fn app_with_double_and_ping() -> App {
let mut app = App::new();
app.bind_brink_fn::<(), _, _>("double", |args| {
args.first().and_then(Value::as_int).unwrap_or(0) * 2
});
app.bind_brink_command::<(), Ping>("ping");
app
}
#[test]
fn pure_fn_resolves_inline() {
let app = app_with_double_and_ping();
let bindings = app.world().resource::<BrinkBindings<()>>();
let handler = bindings.handler();
match handler.call("double", &[Value::Int(21)]) {
ExternalResult::Resolved(Value::Int(42)) => {}
other => panic!("expected Resolved(Int(42)), got {other:?}"),
}
assert_eq!(handler.queued_len(), 0, "pure fn buffers nothing");
}
#[test]
fn command_buffers_trigger_and_returns_reply() {
let app = app_with_double_and_ping();
let bindings = app.world().resource::<BrinkBindings<()>>();
let handler = bindings.handler();
match handler.call("ping", &[Value::from("hi")]) {
ExternalResult::Resolved(Value::Int(2)) => {}
other => panic!("expected Resolved(Int(2)), got {other:?}"),
}
assert_eq!(handler.queued_len(), 1, "command buffers one trigger");
}
#[test]
fn unknown_name_falls_back() {
let app = app_with_double_and_ping();
let bindings = app.world().resource::<BrinkBindings<()>>();
let handler = bindings.handler();
match handler.call("nonexistent", &[]) {
ExternalResult::Fallback => {}
other => panic!("expected Fallback, got {other:?}"),
}
}
#[test]
fn bad_command_args_resolve_null_without_buffering() {
let app = app_with_double_and_ping();
let bindings = app.world().resource::<BrinkBindings<()>>();
let handler = bindings.handler();
match handler.call("ping", &[Value::Int(7)]) {
ExternalResult::Resolved(Value::Null) => {}
other => panic!("expected Resolved(Null), got {other:?}"),
}
assert_eq!(handler.queued_len(), 0, "failed parse buffers nothing");
}
#[test]
fn e2e_pure_fn_value_appears_in_text() {
let (program, tables, _ctx) =
compile_test_story("EXTERNAL double(x)\nResult: {double(21)}.\n-> END\n");
let app = app_with_double_and_ping();
let bindings = app.world().resource::<BrinkBindings<()>>();
let handler = bindings.handler();
let (mut flow, mut ctx) = FlowInstance::new_at_root(&program);
let mut text = String::new();
loop {
let line = flow
.step_single_line::<FastRng>(&program, &tables, &mut ctx, &handler, None)
.unwrap();
text.push_str(line.text());
if line.is_terminal() {
break;
}
}
assert!(
text.contains("Result: 42"),
"expected 'Result: 42' in story text; got {text:?}"
);
}
#[test]
fn e2e_command_triggers_event() {
#[derive(Resource, Default)]
struct PingLog(Vec<String>);
let (program, tables, _ctx) =
compile_test_story("EXTERNAL ping(label)\nA{ping(\"hi\")}B\n-> END\n");
let mut app = app_with_double_and_ping();
app.init_resource::<PingLog>();
app.add_observer(|on: On<Ping>, mut log: ResMut<PingLog>| {
log.0.push(on.event().label.clone());
});
let triggers = {
let bindings = app.world().resource::<BrinkBindings<()>>();
let handler = bindings.handler();
let (mut flow, mut ctx) = FlowInstance::new_at_root(&program);
loop {
let line = flow
.step_single_line::<FastRng>(&program, &tables, &mut ctx, &handler, None)
.unwrap();
if line.is_terminal() {
break;
}
}
handler.queued.into_inner()
};
assert_eq!(triggers.len(), 1, "exactly one ping trigger buffered");
for trigger in triggers {
trigger(app.world_mut());
}
let log = app.world().resource::<PingLog>();
assert_eq!(
log.0,
vec!["hi".to_string()],
"observer should see ping(\"hi\")"
);
}
#[test]
fn derived_from_ink_args_parses_strictly() {
let ok = SetVolume::from_ink_args(&[Value::Int(2), Value::Float(0.5)]).unwrap();
assert_eq!(
ok,
SetVolume {
channel: 2,
level: 0.5
}
);
assert!(matches!(ok.reply(), Value::Null));
assert_eq!(
SetVolume::from_ink_args(&[Value::Int(2)]),
Err(BrinkArgError::Count {
expected: 2,
got: 1
})
);
assert_eq!(
SetVolume::from_ink_args(&[Value::Int(2), Value::Int(3)]),
Err(BrinkArgError::Type {
index: 1,
expected: "float"
})
);
}
#[test]
fn e2e_derived_command_triggers_event() {
#[derive(Resource, Default)]
struct VolumeLog(Vec<(i32, f32)>);
let (program, tables, _ctx) =
compile_test_story("EXTERNAL set_volume(ch, lvl)\nA{set_volume(2, 0.5)}B\n-> END\n");
let mut app = App::new();
app.bind_brink_command::<(), SetVolume>("set_volume");
app.init_resource::<VolumeLog>();
app.add_observer(|on: On<SetVolume>, mut log: ResMut<VolumeLog>| {
log.0.push((on.event().channel, on.event().level));
});
let triggers = {
let bindings = app.world().resource::<BrinkBindings<()>>();
let handler = bindings.handler();
let (mut flow, mut ctx) = FlowInstance::new_at_root(&program);
loop {
let line = flow
.step_single_line::<FastRng>(&program, &tables, &mut ctx, &handler, None)
.unwrap();
if line.is_terminal() {
break;
}
}
handler.queued.into_inner()
};
assert_eq!(triggers.len(), 1);
for trigger in triggers {
trigger(app.world_mut());
}
let log = app.world().resource::<VolumeLog>();
assert_eq!(
log.0,
vec![(2, 0.5)],
"observer should see set_volume(2, 0.5)"
);
}
#[derive(Component)]
struct Enemy;
fn enemy_count(In((_entity, _args)): In<BrinkQueryInput>, enemies: Query<&Enemy>) -> Value {
#[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
Value::Int(enemies.iter().count() as i32)
}
#[test]
fn call_ink_function_resolves_world_query() {
use crate::BrinkFlowRequest;
use crate::test_support::{add_story_assets, make_test_app};
let mut app = make_test_app();
app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);
let (program, tables, ctx) = compile_test_story(
"EXTERNAL enemy_count()\n-> END\n=== function can_spawn() ===\n~ return enemy_count() < 3\n",
);
let story = add_story_assets(&mut app, program, tables, ctx);
app.world_mut().spawn(Enemy);
app.world_mut().spawn(Enemy);
let entity = app
.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build())
.id();
app.update();
let result = call_ink_function::<()>(app.world_mut(), entity, "can_spawn", &[]).unwrap();
assert_eq!(result.as_bool(), Some(true), "2 enemies < 3 → can spawn");
app.world_mut().spawn(Enemy);
app.world_mut().spawn(Enemy);
let result = call_ink_function::<()>(app.world_mut(), entity, "can_spawn", &[]).unwrap();
assert_eq!(
result.as_bool(),
Some(false),
"4 enemies !< 3 → cannot spawn"
);
}
#[test]
fn call_ink_function_pure_and_errors() {
use crate::BrinkFlowRequest;
use crate::test_support::{add_story_assets, make_test_app};
let mut app = make_test_app();
app.bind_brink_fn::<(), _, _>("triple", |args| {
args.first().and_then(Value::as_int).unwrap_or(0) * 3
});
let (program, tables, ctx) = compile_test_story(
"EXTERNAL triple(n)\n-> END\n=== function scaled(n) ===\n~ return triple(n) + 1\n",
);
let story = add_story_assets(&mut app, program, tables, ctx);
let entity = app
.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build())
.id();
app.update();
let result =
call_ink_function::<()>(app.world_mut(), entity, "scaled", &[Value::Int(7)]).unwrap();
assert_eq!(result, Value::Int(22));
let err = call_ink_function::<()>(app.world_mut(), entity, "nope", &[]).unwrap_err();
assert!(
matches!(err, BrinkCallError::FunctionNotFound(_)),
"got {err:?}"
);
}
#[test]
fn advance_flow_resolves_inline_query_during_playback() {
use crate::BrinkFlowRequest;
use crate::test_support::{add_story_assets, make_test_app};
let mut app = make_test_app();
app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);
let (program, tables, ctx) =
compile_test_story("EXTERNAL enemy_count()\nEnemies near: {enemy_count()}.\n-> END\n");
let story = add_story_assets(&mut app, program, tables, ctx);
app.world_mut().spawn(Enemy);
app.world_mut().spawn(Enemy);
let entity = app
.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build())
.id();
app.update();
let line = advance_flow::<()>(app.world_mut(), entity).unwrap();
assert!(
line.text().contains("Enemies near: 2."),
"inline query should resolve to 2; got {:?}",
line.text()
);
}
#[test]
#[expect(clippy::type_complexity, reason = "bevy driver closure query tuple")]
fn step_one_query_pauses_and_plugin_resolver_resumes() {
use crate::test_support::{add_story_assets, make_test_app};
use crate::{
Advance, BrinkContext, BrinkFlow, BrinkFlowRequest, BrinkLocale, BrinkProgram,
};
use bevy_app::Update;
#[derive(Resource, Default)]
struct Lines(Vec<String>);
let mut app = make_test_app();
app.init_resource::<Lines>();
app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);
let (program, tables, ctx) =
compile_test_story("EXTERNAL enemy_count()\nEnemies near: {enemy_count()}.\n-> END\n");
let story = add_story_assets(&mut app, program, tables, ctx);
app.world_mut().spawn(Enemy);
app.world_mut().spawn(Enemy);
app.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build());
app.add_systems(
Update,
|mut flows: Query<(
Entity,
&mut BrinkFlow<()>,
&mut BrinkContext<()>,
&BrinkProgram<()>,
&BrinkLocale<()>,
)>,
programs: Res<Assets<ProgramAsset>>,
tables: Res<Assets<LineTablesAsset>>,
bindings: Res<BrinkBindings<()>>,
mut commands: Commands,
mut out: ResMut<Lines>| {
for (entity, mut flow, mut ctx, prog, loc) in &mut flows {
if flow.inner.has_pending_external() {
continue; }
let (Some(p), Some(t)) = (programs.get(&prog.handle), tables.get(&loc.handle))
else {
continue;
};
let handler = bindings.handler();
if let Ok(Advance::Line(line)) = flow.step_one(
&p.program,
&t.tables,
&mut ctx.inner,
&handler,
entity,
&mut commands,
) {
out.0.push(line.text().to_string());
}
handler.flush(&mut commands);
}
},
);
for _ in 0..6 {
app.update();
}
let lines = &app.world().resource::<Lines>().0;
assert!(
lines.iter().any(|l| l.contains("Enemies near: 2.")),
"expected the resolved inline-query line; got {lines:?}"
);
}
}