use std::marker::PhantomData;
use bevy_asset::{Assets, Handle};
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::query::Without;
use bevy_ecs::system::{Commands, Query, Res, ResMut};
use bevy_log::{error, warn};
use brink_runtime::{Context, FlowInstance};
use crate::asset::{BrinkStory, BrinkStoryAsset, ProgramAsset};
use crate::flow::BrinkFlow;
use crate::globals::{BrinkContext, BrinkGlobals};
#[derive(Default, Clone, Debug)]
pub enum FlowStart {
#[default]
Root,
Address(String),
}
#[derive(Default, Clone, Debug)]
pub enum ContextSeed {
#[default]
FromGlobals,
FromInitial,
Custom(Context),
}
#[derive(Component, bon::Builder)]
pub struct BrinkFlowRequest<M: Send + Sync + 'static = ()> {
pub story: Handle<BrinkStoryAsset>,
#[builder(default)]
pub start: FlowStart,
#[builder(default)]
pub seed: ContextSeed,
#[builder(skip)]
_marker: PhantomData<fn() -> M>,
}
#[expect(
clippy::needless_pass_by_value,
reason = "bevy systems take Res/Query by value"
)]
#[expect(
clippy::too_many_arguments,
reason = "bevy system: flow + globals + locale assets/resources for spawn-time locale reconcile"
)]
pub fn fulfill_flow_requests<M: Send + Sync + 'static>(
requests: Query<(Entity, &BrinkFlowRequest<M>), Without<BrinkFlow<M>>>,
stories: Res<Assets<BrinkStoryAsset>>,
programs: Res<Assets<ProgramAsset>>,
globals: Option<Res<BrinkGlobals<M>>>,
current_locale: Option<Res<crate::locale::BrinkCurrentLocale<M>>>,
locales: Res<Assets<crate::locale::LocaleAsset>>,
mut line_tables: ResMut<Assets<crate::asset::LineTablesAsset>>,
mut cache: ResMut<crate::locale::LocalizedTablesCache<M>>,
mut commands: Commands,
) {
let mut globals_snapshot: Option<Context> = globals.as_ref().map(|g| g.inner.clone());
for (entity, req) in &requests {
let Some(bundle) = stories.get(&req.story) else {
continue;
};
let Some(program_asset) = programs.get(&bundle.program) else {
continue;
};
let flow = match &req.start {
FlowStart::Root => {
let (flow, _ctx) = FlowInstance::new_at_root(&program_asset.program);
flow
}
FlowStart::Address(name) => {
let Some((idx, _)) = program_asset.program.find_address(name) else {
error!("BrinkFlowRequest: knot '{name}' not found; removing request");
commands.entity(entity).remove::<BrinkFlowRequest<M>>();
continue;
};
let (flow, _ctx) = FlowInstance::new_at(&program_asset.program, idx);
flow
}
};
let starting_context = match &req.seed {
ContextSeed::FromGlobals => {
if let Some(ctx) = &globals_snapshot {
ctx.clone()
} else {
let ctx = program_asset.initial_context.clone();
commands.insert_resource(BrinkGlobals::<M>::new(ctx.clone()));
globals_snapshot = Some(ctx.clone());
ctx
}
}
ContextSeed::FromInitial => program_asset.initial_context.clone(),
ContextSeed::Custom(ctx) => ctx.clone(),
};
let base_handle = bundle.line_tables.clone();
let active_handle = crate::locale::initial_locale_handle::<M>(
&base_handle,
program_asset,
current_locale.as_deref(),
&locales,
&mut cache,
&mut line_tables,
);
let mut entity_cmds = commands.entity(entity);
entity_cmds.remove::<BrinkFlowRequest<M>>();
entity_cmds.insert((
BrinkFlow::<M>::new(flow),
BrinkContext::<M>::new(starting_context.clone()),
BrinkStory::<M>::new(bundle.program.clone(), active_handle),
crate::locale::BrinkBaseLocale::<M>::new(base_handle),
));
#[cfg(feature = "dev")]
entity_cmds.insert(crate::replay::BrinkReplayLog::<M>::new(
starting_context,
req.start.clone(),
req.story.clone(),
));
}
}
#[cfg(debug_assertions)]
#[expect(clippy::type_complexity, reason = "bevy query filter type")]
pub fn warn_post_fulfillment_mutations<M: Send + Sync + 'static>(
misuse: Query<
Entity,
(
bevy_ecs::query::With<BrinkFlowRequest<M>>,
bevy_ecs::query::With<BrinkFlow<M>>,
),
>,
) {
for entity in &misuse {
warn!(
"entity {entity:?} has both BrinkFlowRequest<M> and BrinkFlow<M> — \
mutating the request after fulfillment is a no-op. To re-spawn, \
despawn the entity and spawn a fresh request."
);
}
}
#[cfg(not(debug_assertions))]
pub fn warn_post_fulfillment_mutations<M: Send + Sync + 'static>() {}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{add_story_assets, compile_test_story, make_test_app};
#[test]
fn fulfillment_replaces_request_with_flow_components() {
let mut app = make_test_app();
let (program, tables, ctx) =
compile_test_story("=== start ===\nhello\n* [Continue] -> END\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 world = app.world();
let entity_ref = world.entity(entity);
assert!(
entity_ref.contains::<BrinkFlow<()>>(),
"fulfilled entity should have BrinkFlow"
);
assert!(
entity_ref.contains::<crate::BrinkProgram<()>>(),
"fulfilled entity should have BrinkProgram"
);
assert!(
entity_ref.contains::<crate::BrinkLocale<()>>(),
"fulfilled entity should have BrinkLocale"
);
assert!(
entity_ref.contains::<BrinkContext<()>>(),
"fulfilled entity should have BrinkContext"
);
assert!(
!entity_ref.contains::<BrinkFlowRequest<()>>(),
"request component should be removed after fulfillment"
);
assert!(
world.contains_resource::<BrinkGlobals<()>>(),
"globals should be inserted on first fulfillment"
);
}
#[test]
#[cfg(feature = "dev")]
fn fulfillment_attaches_replay_log_in_dev() {
let mut app = make_test_app();
let (program, tables, ctx) =
compile_test_story("=== start ===\nhello\n* [Continue] -> END\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();
assert!(
app.world()
.entity(entity)
.contains::<crate::replay::BrinkReplayLog<()>>(),
"BrinkReplayLog should be attached when dev feature is enabled"
);
}
#[test]
fn fulfillment_removes_request_for_unknown_address() {
let mut app = make_test_app();
let (program, tables, ctx) = compile_test_story(
"=== start ===\nhello\n* [Continue] -> END\n=== outro ===\nbye\n-> END\n",
);
let story = add_story_assets(&mut app, program, tables, ctx);
let entity = app
.world_mut()
.spawn(
BrinkFlowRequest::<()>::builder()
.story(story)
.start(FlowStart::Address("nonexistent_knot".to_string()))
.build(),
)
.id();
app.update();
let entity_ref = app.world().entity(entity);
assert!(
!entity_ref.contains::<BrinkFlowRequest<()>>(),
"request should be removed when address can't be resolved"
);
assert!(
!entity_ref.contains::<BrinkFlow<()>>(),
"no flow should materialize for unresolvable address"
);
}
#[test]
fn fulfillment_resolves_named_address() {
let mut app = make_test_app();
let (program, tables, ctx) = compile_test_story(
"=== start ===\nhello\n* [Continue] -> END\n=== outro ===\nbye\n-> END\n",
);
let story = add_story_assets(&mut app, program, tables, ctx);
let entity = app
.world_mut()
.spawn(
BrinkFlowRequest::<()>::builder()
.story(story)
.start(FlowStart::Address("outro".to_string()))
.build(),
)
.id();
app.update();
assert!(
app.world().entity(entity).contains::<BrinkFlow<()>>(),
"flow should materialize when address resolves"
);
}
#[test]
fn multiple_requests_share_globals() {
let mut app = make_test_app();
let (program, tables, ctx) =
compile_test_story("VAR shared_counter = 0\n=== start ===\nhi\n* [Continue] -> END\n");
let story = add_story_assets(&mut app, program, tables, ctx);
let e1 = app
.world_mut()
.spawn(
BrinkFlowRequest::<()>::builder()
.story(story.clone())
.build(),
)
.id();
let e2 = app
.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build())
.id();
app.update();
let world = app.world();
assert!(world.entity(e1).contains::<BrinkFlow<()>>());
assert!(world.entity(e2).contains::<BrinkFlow<()>>());
assert!(world.contains_resource::<BrinkGlobals<()>>());
}
}