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;
#[cfg(debug_assertions)]
use bevy_log::warn;
use brink_runtime::{FlowInstance, FlowLocal, World};
use crate::asset::{BrinkStory, BrinkStoryAsset, ProgramAsset};
use crate::capability::{CapabilityManifest, CapabilityRegistry, check_load_capability_gate};
use crate::flow::BrinkFlow;
use crate::globals::{BrinkContext, BrinkExecMode, BrinkGlobals, BrinkWorldPolicy};
#[derive(Default, Clone, Debug)]
pub enum FlowStart {
#[default]
Root,
Address(String),
}
#[derive(Component, bon::Builder)]
pub struct BrinkFlowRequest<M: Send + Sync + 'static = ()> {
pub story: Handle<BrinkStoryAsset>,
#[builder(default)]
pub start: FlowStart,
#[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>>,
capability_manifest: Res<CapabilityManifest>,
capability_registry: Res<CapabilityRegistry<M>>,
globals: Option<Res<BrinkGlobals<M>>>,
policy: Res<BrinkWorldPolicy<M>>,
exec_mode: Res<BrinkExecMode<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_ready = globals.is_some();
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 story = req
.story
.path()
.map_or_else(|| format!("{:?}", req.story.id()), ToString::to_string);
if let Err(err) = check_load_capability_gate(
&program_asset.program,
&program_asset.effect_rows,
&capability_manifest,
&capability_registry,
story,
) {
error!("BrinkFlowRequest: {err}; removing request");
commands.entity(entity).remove::<BrinkFlowRequest<M>>();
continue;
}
if !globals_ready {
match World::new(&program_asset.program, &policy.policy) {
Ok(world) => {
commands.insert_resource(BrinkGlobals::<M>::new(world));
globals_ready = true;
}
Err(err) => {
error!(
"BrinkFlowRequest: world policy error creating BrinkGlobals: {err}; \
removing request (fix the policy passed to BrinkPlugin::with_policy)"
);
commands.entity(entity).remove::<BrinkFlowRequest<M>>();
continue;
}
}
}
let mut 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
}
};
flow.set_exec_mode(exec_mode.mode);
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(FlowLocal::new()),
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(
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))]
#[expect(
dead_code,
reason = "generic stub kept for API parity with the debug_assertions variant; never called in release/bench profiles"
)]
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(debug_assertions)]
fn default_exec_mode_is_dev_in_debug_build() {
let mut app = make_test_app();
assert_eq!(
app.world().resource::<BrinkExecMode<()>>().mode,
brink_runtime::ExecMode::Dev,
"BrinkPlugin default must resolve to Dev under debug_assertions"
);
let (program, tables, ctx) = compile_test_story("=== start ===\nhi\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_eq!(
app.world()
.entity(entity)
.get::<BrinkFlow<()>>()
.expect("flow materialized")
.inner
.exec_mode(),
brink_runtime::ExecMode::Dev,
"a flow spawned under the default plugin must start in Dev in a debug build"
);
}
#[test]
fn with_exec_mode_override_stamps_spawned_flow() {
let mut app = App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
app.add_plugins(
crate::BrinkPlugin::<()>::default().with_exec_mode(brink_runtime::ExecMode::Prod),
);
assert_eq!(
app.world().resource::<BrinkExecMode<()>>().mode,
brink_runtime::ExecMode::Prod,
"with_exec_mode(Prod) must install a Prod resource"
);
let (program, tables, ctx) = compile_test_story("=== start ===\nhi\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_eq!(
app.world()
.entity(entity)
.get::<BrinkFlow<()>>()
.expect("flow materialized")
.inner
.exec_mode(),
brink_runtime::ExecMode::Prod,
"with_exec_mode(Prod) must stamp Prod onto the spawned flow"
);
}
#[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<()>>());
}
use crate::globals::flow_context_view;
use crate::{Advance, BrinkGlobals};
use bevy_app::App;
use bevy_ecs::system::SystemState;
use brink_runtime::{Scope, Step, StoryStatus, WorldPolicy};
#[derive(bevy_ecs::resource::Resource, Default)]
struct PendingText(std::collections::HashMap<Entity, String>);
fn install_text_accumulator(app: &mut App) {
app.insert_resource(PendingText::default());
app.add_observer(
|trigger: bevy_ecs::observer::On<crate::BrinkLineDelivered<()>>,
mut pending: ResMut<PendingText>| {
let ev = trigger.event();
pending.0.entry(ev.entity).or_default().push_str(&ev.text);
},
);
}
type DriveAllQuery = SystemState<(
Query<
'static,
'static,
(
Entity,
&'static mut BrinkFlow<()>,
&'static mut BrinkContext<()>,
&'static crate::BrinkProgram<()>,
&'static crate::BrinkLocale<()>,
),
>,
ResMut<'static, BrinkGlobals<()>>,
Res<'static, Assets<ProgramAsset>>,
Res<'static, Assets<crate::asset::LineTablesAsset>>,
Commands<'static, 'static>,
)>;
fn drive_all_active_via_advance_until_terminal(app: &mut App) -> Vec<String> {
let mut state: DriveAllQuery = SystemState::new(app.world_mut());
let (mut flows, mut globals, programs, tables, mut commands) =
state.get_mut(app.world_mut()).expect("system params");
let mut driven = Vec::new();
for (entity, mut flow, mut ctx, prog, loc) in &mut flows {
if flow.inner.status() != StoryStatus::Active {
continue;
}
let (Some(p), Some(t)) = (programs.get(&prog.handle), tables.get(&loc.handle)) else {
continue;
};
let mut view = flow_context_view(&mut globals, &mut ctx);
let advance = flow
.advance_until_terminal(
&p.program,
&t.tables,
&mut view,
&brink_runtime::FallbackHandler,
entity,
&mut commands,
)
.expect("advance");
match advance {
Advance::Step(_) => {}
Advance::AwaitingQuery => unreachable!("unexpected pending external in F6.2 tests"),
}
driven.push(entity);
}
state.apply(app.world_mut());
driven
.into_iter()
.map(|entity| {
app.world_mut()
.resource_mut::<PendingText>()
.0
.remove(&entity)
.unwrap_or_default()
})
.collect()
}
#[test]
fn default_policy_two_flows_share_one_world_global() {
let mut app = App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
app.add_plugins(crate::BrinkPlugin::<()>::default());
install_text_accumulator(&mut app);
let (program, tables, ctx) = compile_test_story(
"VAR counter = 0\n~ counter = counter + 1\nCounter is {counter}.\n-> DONE\n",
);
let story = add_story_assets(&mut app, program, tables, ctx);
app.world_mut().spawn(
BrinkFlowRequest::<()>::builder()
.story(story.clone())
.build(),
);
app.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build());
app.update();
let mut texts = drive_all_active_via_advance_until_terminal(&mut app); texts.sort();
assert_eq!(
texts,
vec!["Counter is 1.\n".to_string(), "Counter is 2.\n".to_string()],
"two flows sharing the default (all-World) policy should observe \
cumulative shared state, not independent per-flow copies; got {texts:?}"
);
}
#[test]
fn local_knot_override_isolates_visit_state_while_world_var_stays_shared() {
let mut app = App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
let mut policy = WorldPolicy::default();
policy.overrides.insert("start".to_string(), Scope::Local);
app.add_plugins(crate::BrinkPlugin::<()>::default().with_policy(policy));
install_text_accumulator(&mut app);
let (program, tables, ctx) = compile_test_story(
"VAR shared_visits = 0\n-> start\n=== start ===\n\
~ shared_visits = shared_visits + 1\n\
{start: Hello|Welcome back} (shared {shared_visits}).\n-> DONE\n",
);
let story = add_story_assets(&mut app, program, tables, ctx);
app.world_mut().spawn(
BrinkFlowRequest::<()>::builder()
.story(story.clone())
.build(),
);
app.update(); let flow1_text = drive_all_active_via_advance_until_terminal(&mut app) .remove(0);
assert!(
flow1_text.contains("Hello"),
"flow 1's first-ever visit to a Local-scoped knot should take \
the sequence's first branch; got {flow1_text:?}"
);
assert!(
flow1_text.contains("shared 1"),
"the World-scoped VAR should count flow 1's visit; got {flow1_text:?}"
);
app.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build());
app.update(); let flow2_text = drive_all_active_via_advance_until_terminal(&mut app) .remove(0);
assert!(
flow2_text.contains("Hello"),
"flow 2's own Local visit count should also start fresh, \
independent of flow 1's; got {flow2_text:?}"
);
assert!(
flow2_text.contains("shared 2"),
"the World-scoped VAR should keep counting across flows \
(flow 1's 1, then flow 2's 2); got {flow2_text:?}"
);
}
#[test]
fn unknown_policy_override_surfaces_as_fulfillment_error_not_panic() {
let mut app = App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
let mut policy = WorldPolicy::default();
policy
.overrides
.insert("does_not_exist".to_string(), Scope::Local);
app.add_plugins(crate::BrinkPlugin::<()>::default().with_policy(policy));
let (program, tables, ctx) = compile_test_story("Hello.\n-> 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::<BrinkFlow<()>>(),
"flow should not materialize when the policy fails to resolve"
);
assert!(
!app.world()
.entity(entity)
.contains::<BrinkFlowRequest<()>>(),
"the invalid request should be removed, not left pending forever"
);
assert!(
!app.world().contains_resource::<BrinkGlobals<()>>(),
"BrinkGlobals must never be created from a policy that fails to resolve"
);
}
use crate::globals::{load_flow_state, save_flow_state};
use brink_format::Value;
use brink_runtime::ContextAccess;
const SAVE_TEST_SRC: &str = "VAR shared_count = 0\nVAR mood = 0\n-> greet\n\
=== greet ===\n\
~ mood = mood + 1\n\
~ shared_count = shared_count + 1\n\
Greeting mood={mood} visits={READ_COUNT(-> greet)} shared={shared_count}\n\
* [Again] -> greet\n\
* [Done] -> END\n";
fn save_test_policy() -> WorldPolicy {
let mut policy = WorldPolicy::default();
policy.overrides.insert("mood".to_string(), Scope::Local);
policy.overrides.insert("greet".to_string(), Scope::Local);
policy
}
type FlowQuery = SystemState<(
Query<
'static,
'static,
(
&'static mut BrinkFlow<()>,
&'static mut BrinkContext<()>,
&'static crate::BrinkProgram<()>,
&'static crate::BrinkLocale<()>,
),
>,
ResMut<'static, BrinkGlobals<()>>,
Res<'static, Assets<ProgramAsset>>,
Res<'static, Assets<crate::LineTablesAsset>>,
Commands<'static, 'static>,
)>;
fn drive_entity(app: &mut App, entity: Entity) -> (String, Step) {
let mut state: FlowQuery = SystemState::new(app.world_mut());
let (mut flows, mut globals, programs, tables, mut commands) =
state.get_mut(app.world_mut()).expect("system params");
let (mut flow, mut ctx, prog, loc) = flows.get_mut(entity).expect("flow components");
let program = &programs.get(&prog.handle).expect("program asset").program;
let line_tables = &tables.get(&loc.handle).expect("line tables asset").tables;
let mut view = flow_context_view(&mut globals, &mut ctx);
let advance = flow
.advance_until_terminal(
program,
line_tables,
&mut view,
&brink_runtime::FallbackHandler,
entity,
&mut commands,
)
.expect("advance");
state.apply(app.world_mut());
let step = match advance {
Advance::Step(step) => step,
Advance::AwaitingQuery => unreachable!("unexpected pending external in F6.3 tests"),
};
let text = app
.world_mut()
.resource_mut::<PendingText>()
.0
.remove(&entity)
.unwrap_or_default();
(text, step)
}
fn choose_entity(app: &mut App, entity: Entity, index: usize) {
let mut state: FlowQuery = SystemState::new(app.world_mut());
let (mut flows, mut globals, _programs, _tables, _commands) =
state.get_mut(app.world_mut()).expect("system params");
let (mut flow, mut ctx, _prog, _loc) = flows.get_mut(entity).expect("flow components");
let mut view = flow_context_view(&mut globals, &mut ctx);
flow.choose(&mut view, index).expect("choose");
state.apply(app.world_mut());
}
fn spawn_fulfilled(app: &mut App, story: &Handle<BrinkStoryAsset>, start: FlowStart) -> Entity {
let entity = app
.world_mut()
.spawn(
BrinkFlowRequest::<()>::builder()
.story(story.clone())
.start(start)
.build(),
)
.id();
app.update();
entity
}
fn app_with_save_policy() -> App {
let mut app = App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
app.add_plugins(crate::BrinkPlugin::<()>::default().with_policy(save_test_policy()));
install_text_accumulator(&mut app);
app
}
#[test]
#[expect(
clippy::similar_names,
reason = "the paired a/b entity naming is the point of the test"
)]
fn full_roundtrip_world_plus_two_entities() {
let mut app1 = app_with_save_policy();
let (program1, tables1, ctx1) = compile_test_story(SAVE_TEST_SRC);
let story1 = add_story_assets(&mut app1, program1, tables1, ctx1);
let entity_a = spawn_fulfilled(&mut app1, &story1, FlowStart::Root);
let entity_b = spawn_fulfilled(&mut app1, &story1, FlowStart::Root);
let (text_a, _) = drive_entity(&mut app1, entity_a);
assert!(
text_a.contains("mood=1") && text_a.contains("visits=1"),
"flow A's first pass; got {text_a:?}"
);
let (text_b1, _) = drive_entity(&mut app1, entity_b);
assert!(
text_b1.contains("mood=1") && text_b1.contains("visits=1"),
"flow B's first pass; got {text_b1:?}"
);
choose_entity(&mut app1, entity_b, 0); let (text_b2, _) = drive_entity(&mut app1, entity_b);
assert!(
text_b2.contains("mood=2") && text_b2.contains("visits=2"),
"flow B's second pass; got {text_b2:?}"
);
let (world_save, save_a, save_b) = {
let mut state: FlowQuery = SystemState::new(app1.world_mut());
let (mut flows, mut globals, programs, _tables, _commands) =
state.get_mut(app1.world_mut()).expect("system params");
let handle = flows.get(entity_a).expect("flow a").2.handle.clone();
let program = &programs.get(&handle).expect("program asset").program;
let world_save = globals.save_state(program);
let (_flow_a, mut ctx_a, _p, _l) = flows.get_mut(entity_a).expect("flow a");
let save_a = save_flow_state(&mut globals, &mut ctx_a, program);
let (_flow_b, mut ctx_b, _p, _l) = flows.get_mut(entity_b).expect("flow b");
let save_b = save_flow_state(&mut globals, &mut ctx_b, program);
(world_save, save_a, save_b)
};
assert_eq!(save_a.globals.get("mood"), Some(&Value::Int(1)));
assert_eq!(save_b.globals.get("mood"), Some(&Value::Int(2)));
assert_eq!(save_a.globals.get("shared_count"), Some(&Value::Int(3)));
assert_eq!(save_b.globals.get("shared_count"), Some(&Value::Int(3)));
assert_eq!(world_save.globals.get("shared_count"), Some(&Value::Int(3)));
let mut app2 = app_with_save_policy();
let (program2, tables2, ctx2) = compile_test_story(SAVE_TEST_SRC);
let story2 = add_story_assets(&mut app2, program2, tables2, ctx2);
let entity_a2 =
spawn_fulfilled(&mut app2, &story2, FlowStart::Address("greet".to_string()));
let entity_b2 =
spawn_fulfilled(&mut app2, &story2, FlowStart::Address("greet".to_string()));
{
let mut state: FlowQuery = SystemState::new(app2.world_mut());
let (mut flows, mut globals, programs, _tables, _commands) =
state.get_mut(app2.world_mut()).expect("system params");
let handle = flows.get(entity_a2).expect("flow a2").2.handle.clone();
let program = &programs.get(&handle).expect("program asset").program;
let world_report = globals.load_state(program, &world_save);
assert!(
world_report.is_clean(),
"world load should be clean: {world_report:?}"
);
let (_flow, mut ctx_a2, _p, _l) = flows.get_mut(entity_a2).expect("flow a2");
let report_a = load_flow_state(&mut globals, &mut ctx_a2, program, &save_a);
assert!(
report_a.is_clean(),
"entity A load should be clean: {report_a:?}"
);
let (_flow, mut ctx_b2, _p, _l) = flows.get_mut(entity_b2).expect("flow b2");
let report_b = load_flow_state(&mut globals, &mut ctx_b2, program, &save_b);
assert!(
report_b.is_clean(),
"entity B load should be clean: {report_b:?}"
);
}
let (resumed_a_text, _) = drive_entity(&mut app2, entity_a2);
assert!(
resumed_a_text.contains("mood=2") && resumed_a_text.contains("visits=1"),
"flow A2 should resume from its own restored state (mood 1->2, \
greet visits still 1, unbumped by address-entry); got {resumed_a_text:?}",
);
let (resumed_b_text, _) = drive_entity(&mut app2, entity_b2);
assert!(
resumed_b_text.contains("mood=3") && resumed_b_text.contains("visits=2"),
"flow B2 should resume from ITS OWN restored state (mood 2->3, \
greet visits still 2) — distinct from flow A2's; got {resumed_b_text:?}",
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "one scope-routing scenario checked from three vantage points"
)]
fn entity_load_routes_local_to_flow_local_and_world_stays_shared() {
let mut app = app_with_save_policy();
let (program, tables, ctx) = compile_test_story(SAVE_TEST_SRC);
let story = add_story_assets(&mut app, program, tables, ctx);
let entity = spawn_fulfilled(&mut app, &story, FlowStart::Address("greet".to_string()));
let other = spawn_fulfilled(&mut app, &story, FlowStart::Address("greet".to_string()));
let mut save = brink_runtime::SaveState {
version: brink_runtime::SAVE_FORMAT_VERSION,
globals: std::collections::BTreeMap::new(),
global_ids: std::collections::BTreeMap::new(),
visits: Vec::new(),
turns: Vec::new(),
turn_index: 0,
rng_seed: 0,
previous_random: 0,
suspended: None,
};
save.globals.insert("mood".to_string(), Value::Int(42));
save.globals
.insert("shared_count".to_string(), Value::Int(7));
{
let mut state: FlowQuery = SystemState::new(app.world_mut());
let (mut flows, mut globals, programs, _tables, _commands) =
state.get_mut(app.world_mut()).expect("system params");
let handle = flows.get(entity).expect("flow").2.handle.clone();
let program = &programs.get(&handle).expect("program asset").program;
let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
let report = load_flow_state(&mut globals, &mut ctx, program, &save);
assert!(report.is_clean(), "load should be clean: {report:?}");
}
let mood_idx = {
let programs = app.world().resource::<Assets<ProgramAsset>>();
let handle = app
.world()
.entity(entity)
.get::<crate::BrinkProgram<()>>()
.expect("BrinkProgram")
.handle
.clone();
programs
.get(&handle)
.expect("program asset")
.program
.global_index("mood")
.expect("mood global")
};
let shared_idx = {
let programs = app.world().resource::<Assets<ProgramAsset>>();
let handle = app
.world()
.entity(entity)
.get::<crate::BrinkProgram<()>>()
.expect("BrinkProgram")
.handle
.clone();
programs
.get(&handle)
.expect("program asset")
.program
.global_index("shared_count")
.expect("shared_count global")
};
{
let mut state: FlowQuery = SystemState::new(app.world_mut());
let (mut flows, mut globals, _programs, _tables, _commands) =
state.get_mut(app.world_mut()).expect("system params");
let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
let view = flow_context_view(&mut globals, &mut ctx);
assert_eq!(
view.global(mood_idx),
&Value::Int(42),
"the loaded entity's own view should see the restored Local mood"
);
assert_eq!(
view.global(shared_idx),
&Value::Int(7),
"the loaded entity's own view should see the restored World shared_count"
);
}
{
let globals = app.world().resource::<BrinkGlobals<()>>();
assert_ne!(
globals.inner.global(mood_idx),
&Value::Int(42),
"Local-scoped mood must NOT have been written into the shared World"
);
assert_eq!(
globals.inner.global(shared_idx),
&Value::Int(7),
"World-scoped shared_count should have rewritten the shared World directly"
);
}
{
let mut state: FlowQuery = SystemState::new(app.world_mut());
let (mut flows, mut globals, _programs, _tables, _commands) =
state.get_mut(app.world_mut()).expect("system params");
let (_flow, mut ctx, _p, _l) = flows.get_mut(other).expect("other flow");
let view = flow_context_view(&mut globals, &mut ctx);
assert_ne!(
view.global(mood_idx),
&Value::Int(42),
"a different flow must not see another entity's private mood"
);
assert_eq!(
view.global(shared_idx),
&Value::Int(7),
"a different flow should see the same shared shared_count"
);
}
}
#[test]
fn load_report_surfaces_unknown_globals() {
let mut app = app_with_save_policy();
let (program, tables, ctx) = compile_test_story(SAVE_TEST_SRC);
let story = add_story_assets(&mut app, program, tables, ctx);
let entity = spawn_fulfilled(&mut app, &story, FlowStart::Address("greet".to_string()));
let mut save = brink_runtime::SaveState {
version: brink_runtime::SAVE_FORMAT_VERSION,
globals: std::collections::BTreeMap::new(),
global_ids: std::collections::BTreeMap::new(),
visits: Vec::new(),
turns: Vec::new(),
turn_index: 0,
rng_seed: 0,
previous_random: 0,
suspended: None,
};
save.globals.insert("mood".to_string(), Value::Int(5));
save.globals
.insert("does_not_exist".to_string(), Value::Int(99));
let (report, mood_idx) = {
let mut state: FlowQuery = SystemState::new(app.world_mut());
let (mut flows, mut globals, programs, _tables, _commands) =
state.get_mut(app.world_mut()).expect("system params");
let handle = flows.get(entity).expect("flow").2.handle.clone();
let program = &programs.get(&handle).expect("program asset").program;
let mood_idx = program.global_index("mood").expect("mood global");
let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
let report = load_flow_state(&mut globals, &mut ctx, program, &save);
(report, mood_idx)
};
assert!(!report.is_clean(), "report should not be clean: {report:?}");
assert_eq!(report.unknown_globals, vec!["does_not_exist".to_string()]);
let mut state: FlowQuery = SystemState::new(app.world_mut());
let (mut flows, mut globals, _programs, _tables, _commands) =
state.get_mut(app.world_mut()).expect("system params");
let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
let view = flow_context_view(&mut globals, &mut ctx);
assert_eq!(
view.global(mood_idx),
&Value::Int(5),
"the known global should still apply even though another was unknown"
);
}
#[test]
fn per_marker_capability_gate_admits_one_marker_and_rejects_another() {
use bevy_asset::AssetPlugin;
use bevy_ecs::component::Component;
use crate::asset::{LineTablesAsset, fresh_context};
use crate::capability::{
BrinkCapabilityAppExt as _, CapabilityEffects, CapabilityManifest,
CapabilityManifestExternal,
};
#[derive(Component)]
struct Transform;
struct MarkerHasCapability;
struct MarkerMissingCapability;
let mut app = App::new();
app.add_plugins(AssetPlugin::default());
app.add_plugins(crate::BrinkPlugin::<MarkerHasCapability>::default());
app.add_plugins(crate::BrinkPlugin::<MarkerMissingCapability>::default());
app.register_capability::<MarkerHasCapability, Transform>("Transform");
let mut manifest = CapabilityManifest::default();
manifest.externals.push(CapabilityManifestExternal {
name: "get_position".to_string(),
effects: CapabilityEffects {
reads: vec!["Transform".to_string()],
writes: vec![],
detect: std::collections::BTreeMap::new(),
},
});
app.insert_resource(manifest);
let source = "EXTERNAL get_position(id)\n=== start ===\n\
~ temp x = get_position(0)\nHello.\n-> END\n";
let out = brink_compiler::compile("t.ink", move |p| {
if p == "t.ink" {
Ok(source.to_string())
} else {
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
}
})
.expect("compile");
let mut inkb = Vec::new();
brink_format::write_inkb(&out.data, &mut inkb);
let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
let (program, tables) = brink_runtime::link(&loaded).expect("link");
let initial_context = fresh_context(&program);
let world = app.world_mut();
let program_handle = world
.resource_mut::<Assets<ProgramAsset>>()
.add(ProgramAsset {
program,
initial_context,
effect_rows: loaded.effect_rows,
});
let tables_handle = world
.resource_mut::<Assets<LineTablesAsset>>()
.add(LineTablesAsset { tables });
let story_handle = world
.resource_mut::<Assets<BrinkStoryAsset>>()
.add(BrinkStoryAsset {
program: program_handle,
line_tables: tables_handle,
});
let entity_has = app
.world_mut()
.spawn(
BrinkFlowRequest::<MarkerHasCapability>::builder()
.story(story_handle.clone())
.build(),
)
.id();
let entity_missing = app
.world_mut()
.spawn(
BrinkFlowRequest::<MarkerMissingCapability>::builder()
.story(story_handle)
.build(),
)
.id();
app.update();
let world = app.world();
assert!(
world
.entity(entity_has)
.contains::<BrinkFlow<MarkerHasCapability>>(),
"marker with Transform registered should load successfully"
);
assert!(
!world
.entity(entity_has)
.contains::<BrinkFlowRequest<MarkerHasCapability>>(),
"fulfilled request should be removed"
);
assert!(
!world
.entity(entity_missing)
.contains::<BrinkFlow<MarkerMissingCapability>>(),
"marker missing Transform must not get a flow — the load must be rejected"
);
assert!(
!world
.entity(entity_missing)
.contains::<BrinkFlowRequest<MarkerMissingCapability>>(),
"the rejected request must be removed too, not left pending forever"
);
}
#[test]
fn single_marker_path_with_no_manifest_is_unaffected_by_the_gate() {
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::<BrinkFlow<()>>(),
"no manifest means no required capabilities, so fulfillment proceeds as before"
);
}
}