1use std::marker::PhantomData;
15
16use bevy_asset::{Assets, Handle};
17use bevy_ecs::component::Component;
18use bevy_ecs::entity::Entity;
19use bevy_ecs::query::Without;
20use bevy_ecs::system::{Commands, Query, Res, ResMut};
21use bevy_log::error;
22#[cfg(debug_assertions)]
27use bevy_log::warn;
28use brink_runtime::{FlowInstance, FlowLocal, World};
29
30use crate::asset::{BrinkStory, BrinkStoryAsset, ProgramAsset};
31use crate::capability::{CapabilityManifest, CapabilityRegistry, check_load_capability_gate};
32use crate::flow::BrinkFlow;
33use crate::globals::{BrinkContext, BrinkExecMode, BrinkGlobals, BrinkWorldPolicy};
34
35#[derive(Default, Clone, Debug)]
37pub enum FlowStart {
38 #[default]
41 Root,
42 Address(String),
45}
46
47#[derive(Component, bon::Builder)]
78pub struct BrinkFlowRequest<M: Send + Sync + 'static = ()> {
79 pub story: Handle<BrinkStoryAsset>,
81 #[builder(default)]
83 pub start: FlowStart,
84 #[builder(skip)]
85 _marker: PhantomData<fn() -> M>,
86}
87
88#[expect(
122 clippy::needless_pass_by_value,
123 reason = "bevy systems take Res/Query by value"
124)]
125#[expect(
126 clippy::too_many_arguments,
127 reason = "bevy system: flow + globals + locale assets/resources for spawn-time locale reconcile"
128)]
129pub fn fulfill_flow_requests<M: Send + Sync + 'static>(
130 requests: Query<(Entity, &BrinkFlowRequest<M>), Without<BrinkFlow<M>>>,
131 stories: Res<Assets<BrinkStoryAsset>>,
132 programs: Res<Assets<ProgramAsset>>,
133 capability_manifest: Res<CapabilityManifest>,
134 capability_registry: Res<CapabilityRegistry<M>>,
135 globals: Option<Res<BrinkGlobals<M>>>,
136 policy: Res<BrinkWorldPolicy<M>>,
137 exec_mode: Res<BrinkExecMode<M>>,
138 current_locale: Option<Res<crate::locale::BrinkCurrentLocale<M>>>,
139 locales: Res<Assets<crate::locale::LocaleAsset>>,
140 mut line_tables: ResMut<Assets<crate::asset::LineTablesAsset>>,
141 mut cache: ResMut<crate::locale::LocalizedTablesCache<M>>,
142 mut commands: Commands,
143) {
144 let mut globals_ready = globals.is_some();
149
150 for (entity, req) in &requests {
151 let Some(bundle) = stories.get(&req.story) else {
152 continue;
153 };
154 let Some(program_asset) = programs.get(&bundle.program) else {
155 continue;
156 };
157
158 let story = req
166 .story
167 .path()
168 .map_or_else(|| format!("{:?}", req.story.id()), ToString::to_string);
169 if let Err(err) = check_load_capability_gate(
170 &program_asset.program,
171 &program_asset.effect_rows,
172 &capability_manifest,
173 &capability_registry,
174 story,
175 ) {
176 error!("BrinkFlowRequest: {err}; removing request");
177 commands.entity(entity).remove::<BrinkFlowRequest<M>>();
178 continue;
179 }
180
181 if !globals_ready {
182 match World::new(&program_asset.program, &policy.policy) {
183 Ok(world) => {
184 commands.insert_resource(BrinkGlobals::<M>::new(world));
185 globals_ready = true;
186 }
187 Err(err) => {
188 error!(
189 "BrinkFlowRequest: world policy error creating BrinkGlobals: {err}; \
190 removing request (fix the policy passed to BrinkPlugin::with_policy)"
191 );
192 commands.entity(entity).remove::<BrinkFlowRequest<M>>();
193 continue;
194 }
195 }
196 }
197
198 let mut flow = match &req.start {
200 FlowStart::Root => {
201 let (flow, _ctx) = FlowInstance::new_at_root(&program_asset.program);
202 flow
203 }
204 FlowStart::Address(name) => {
205 let Some((idx, _)) = program_asset.program.find_address(name) else {
206 error!("BrinkFlowRequest: knot '{name}' not found; removing request");
207 commands.entity(entity).remove::<BrinkFlowRequest<M>>();
208 continue;
209 };
210 let (flow, _ctx) = FlowInstance::new_at(&program_asset.program, idx);
211 flow
212 }
213 };
214 flow.set_exec_mode(exec_mode.mode);
219
220 let base_handle = bundle.line_tables.clone();
225 let active_handle = crate::locale::initial_locale_handle::<M>(
226 &base_handle,
227 program_asset,
228 current_locale.as_deref(),
229 &locales,
230 &mut cache,
231 &mut line_tables,
232 );
233
234 let mut entity_cmds = commands.entity(entity);
236 entity_cmds.remove::<BrinkFlowRequest<M>>();
237 entity_cmds.insert((
238 BrinkFlow::<M>::new(flow),
239 BrinkContext::<M>::new(FlowLocal::new()),
240 BrinkStory::<M>::new(bundle.program.clone(), active_handle),
241 crate::locale::BrinkBaseLocale::<M>::new(base_handle),
242 ));
243
244 #[cfg(feature = "dev")]
247 entity_cmds.insert(crate::replay::BrinkReplayLog::<M>::new(
248 req.start.clone(),
249 req.story.clone(),
250 ));
251 }
252}
253
254#[cfg(debug_assertions)]
260#[expect(clippy::type_complexity, reason = "bevy query filter type")]
261pub fn warn_post_fulfillment_mutations<M: Send + Sync + 'static>(
262 misuse: Query<
263 Entity,
264 (
265 bevy_ecs::query::With<BrinkFlowRequest<M>>,
266 bevy_ecs::query::With<BrinkFlow<M>>,
267 ),
268 >,
269) {
270 for entity in &misuse {
271 warn!(
272 "entity {entity:?} has both BrinkFlowRequest<M> and BrinkFlow<M> — \
273 mutating the request after fulfillment is a no-op. To re-spawn, \
274 despawn the entity and spawn a fresh request."
275 );
276 }
277}
278
279#[cfg(not(debug_assertions))]
288#[expect(
289 dead_code,
290 reason = "generic stub kept for API parity with the debug_assertions variant; never called in release/bench profiles"
291)]
292pub fn warn_post_fulfillment_mutations<M: Send + Sync + 'static>() {}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::test_support::{add_story_assets, compile_test_story, make_test_app};
298
299 #[test]
302 fn fulfillment_replaces_request_with_flow_components() {
303 let mut app = make_test_app();
304 let (program, tables, ctx) =
305 compile_test_story("=== start ===\nhello\n* [Continue] -> END\n");
306 let story = add_story_assets(&mut app, program, tables, ctx);
307
308 let entity = app
309 .world_mut()
310 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
311 .id();
312
313 app.update();
314
315 let world = app.world();
316 let entity_ref = world.entity(entity);
317 assert!(
318 entity_ref.contains::<BrinkFlow<()>>(),
319 "fulfilled entity should have BrinkFlow"
320 );
321 assert!(
322 entity_ref.contains::<crate::BrinkProgram<()>>(),
323 "fulfilled entity should have BrinkProgram"
324 );
325 assert!(
326 entity_ref.contains::<crate::BrinkLocale<()>>(),
327 "fulfilled entity should have BrinkLocale"
328 );
329 assert!(
330 entity_ref.contains::<BrinkContext<()>>(),
331 "fulfilled entity should have BrinkContext"
332 );
333 assert!(
334 !entity_ref.contains::<BrinkFlowRequest<()>>(),
335 "request component should be removed after fulfillment"
336 );
337 assert!(
338 world.contains_resource::<BrinkGlobals<()>>(),
339 "globals should be inserted on first fulfillment"
340 );
341 }
342
343 #[test]
351 #[cfg(debug_assertions)]
352 fn default_exec_mode_is_dev_in_debug_build() {
353 let mut app = make_test_app();
354 assert_eq!(
355 app.world().resource::<BrinkExecMode<()>>().mode,
356 brink_runtime::ExecMode::Dev,
357 "BrinkPlugin default must resolve to Dev under debug_assertions"
358 );
359
360 let (program, tables, ctx) = compile_test_story("=== start ===\nhi\n* [Continue] -> END\n");
361 let story = add_story_assets(&mut app, program, tables, ctx);
362 let entity = app
363 .world_mut()
364 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
365 .id();
366 app.update();
367
368 assert_eq!(
369 app.world()
370 .entity(entity)
371 .get::<BrinkFlow<()>>()
372 .expect("flow materialized")
373 .inner
374 .exec_mode(),
375 brink_runtime::ExecMode::Dev,
376 "a flow spawned under the default plugin must start in Dev in a debug build"
377 );
378 }
379
380 #[test]
383 fn with_exec_mode_override_stamps_spawned_flow() {
384 let mut app = App::new();
385 app.add_plugins(bevy_asset::AssetPlugin::default());
386 app.add_plugins(
387 crate::BrinkPlugin::<()>::default().with_exec_mode(brink_runtime::ExecMode::Prod),
388 );
389 assert_eq!(
390 app.world().resource::<BrinkExecMode<()>>().mode,
391 brink_runtime::ExecMode::Prod,
392 "with_exec_mode(Prod) must install a Prod resource"
393 );
394
395 let (program, tables, ctx) = compile_test_story("=== start ===\nhi\n* [Continue] -> END\n");
396 let story = add_story_assets(&mut app, program, tables, ctx);
397 let entity = app
398 .world_mut()
399 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
400 .id();
401 app.update();
402
403 assert_eq!(
404 app.world()
405 .entity(entity)
406 .get::<BrinkFlow<()>>()
407 .expect("flow materialized")
408 .inner
409 .exec_mode(),
410 brink_runtime::ExecMode::Prod,
411 "with_exec_mode(Prod) must stamp Prod onto the spawned flow"
412 );
413 }
414
415 #[test]
418 #[cfg(feature = "dev")]
419 fn fulfillment_attaches_replay_log_in_dev() {
420 let mut app = make_test_app();
421 let (program, tables, ctx) =
422 compile_test_story("=== start ===\nhello\n* [Continue] -> END\n");
423 let story = add_story_assets(&mut app, program, tables, ctx);
424
425 let entity = app
426 .world_mut()
427 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
428 .id();
429
430 app.update();
431
432 assert!(
433 app.world()
434 .entity(entity)
435 .contains::<crate::replay::BrinkReplayLog<()>>(),
436 "BrinkReplayLog should be attached when dev feature is enabled"
437 );
438 }
439
440 #[test]
443 fn fulfillment_removes_request_for_unknown_address() {
444 let mut app = make_test_app();
445 let (program, tables, ctx) = compile_test_story(
446 "=== start ===\nhello\n* [Continue] -> END\n=== outro ===\nbye\n-> END\n",
447 );
448 let story = add_story_assets(&mut app, program, tables, ctx);
449
450 let entity = app
451 .world_mut()
452 .spawn(
453 BrinkFlowRequest::<()>::builder()
454 .story(story)
455 .start(FlowStart::Address("nonexistent_knot".to_string()))
456 .build(),
457 )
458 .id();
459
460 app.update();
461
462 let entity_ref = app.world().entity(entity);
463 assert!(
464 !entity_ref.contains::<BrinkFlowRequest<()>>(),
465 "request should be removed when address can't be resolved"
466 );
467 assert!(
468 !entity_ref.contains::<BrinkFlow<()>>(),
469 "no flow should materialize for unresolvable address"
470 );
471 }
472
473 #[test]
475 fn fulfillment_resolves_named_address() {
476 let mut app = make_test_app();
477 let (program, tables, ctx) = compile_test_story(
478 "=== start ===\nhello\n* [Continue] -> END\n=== outro ===\nbye\n-> END\n",
479 );
480 let story = add_story_assets(&mut app, program, tables, ctx);
481
482 let entity = app
483 .world_mut()
484 .spawn(
485 BrinkFlowRequest::<()>::builder()
486 .story(story)
487 .start(FlowStart::Address("outro".to_string()))
488 .build(),
489 )
490 .id();
491
492 app.update();
493
494 assert!(
495 app.world().entity(entity).contains::<BrinkFlow<()>>(),
496 "flow should materialize when address resolves"
497 );
498 }
499
500 #[test]
503 fn multiple_requests_share_globals() {
504 let mut app = make_test_app();
505 let (program, tables, ctx) =
506 compile_test_story("VAR shared_counter = 0\n=== start ===\nhi\n* [Continue] -> END\n");
507 let story = add_story_assets(&mut app, program, tables, ctx);
508
509 let e1 = app
510 .world_mut()
511 .spawn(
512 BrinkFlowRequest::<()>::builder()
513 .story(story.clone())
514 .build(),
515 )
516 .id();
517 let e2 = app
518 .world_mut()
519 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
520 .id();
521
522 app.update();
523
524 let world = app.world();
525 assert!(world.entity(e1).contains::<BrinkFlow<()>>());
526 assert!(world.entity(e2).contains::<BrinkFlow<()>>());
527 assert!(world.contains_resource::<BrinkGlobals<()>>());
530 }
531
532 use crate::globals::flow_context_view;
544 use crate::{Advance, BrinkGlobals};
545 use bevy_app::App;
546 use bevy_ecs::system::SystemState;
547 use brink_runtime::{Scope, Step, StoryStatus, WorldPolicy};
548
549 #[derive(bevy_ecs::resource::Resource, Default)]
561 struct PendingText(std::collections::HashMap<Entity, String>);
562
563 fn install_text_accumulator(app: &mut App) {
568 app.insert_resource(PendingText::default());
569 app.add_observer(
570 |trigger: bevy_ecs::observer::On<crate::BrinkLineDelivered<()>>,
571 mut pending: ResMut<PendingText>| {
572 let ev = trigger.event();
573 pending.0.entry(ev.entity).or_default().push_str(&ev.text);
574 },
575 );
576 }
577
578 type DriveAllQuery = SystemState<(
585 Query<
586 'static,
587 'static,
588 (
589 Entity,
590 &'static mut BrinkFlow<()>,
591 &'static mut BrinkContext<()>,
592 &'static crate::BrinkProgram<()>,
593 &'static crate::BrinkLocale<()>,
594 ),
595 >,
596 ResMut<'static, BrinkGlobals<()>>,
597 Res<'static, Assets<ProgramAsset>>,
598 Res<'static, Assets<crate::asset::LineTablesAsset>>,
599 Commands<'static, 'static>,
600 )>;
601
602 fn drive_all_active_via_advance_until_terminal(app: &mut App) -> Vec<String> {
622 let mut state: DriveAllQuery = SystemState::new(app.world_mut());
623 let (mut flows, mut globals, programs, tables, mut commands) =
624 state.get_mut(app.world_mut()).expect("system params");
625
626 let mut driven = Vec::new();
627 for (entity, mut flow, mut ctx, prog, loc) in &mut flows {
628 if flow.inner.status() != StoryStatus::Active {
629 continue;
630 }
631 let (Some(p), Some(t)) = (programs.get(&prog.handle), tables.get(&loc.handle)) else {
632 continue;
633 };
634 let mut view = flow_context_view(&mut globals, &mut ctx);
635 let advance = flow
636 .advance_until_terminal(
637 &p.program,
638 &t.tables,
639 &mut view,
640 &brink_runtime::FallbackHandler,
641 entity,
642 &mut commands,
643 )
644 .expect("advance");
645 match advance {
646 Advance::Step(_) => {}
647 Advance::AwaitingQuery => unreachable!("unexpected pending external in F6.2 tests"),
649 }
650 driven.push(entity);
651 }
652 state.apply(app.world_mut());
653
654 driven
655 .into_iter()
656 .map(|entity| {
657 app.world_mut()
658 .resource_mut::<PendingText>()
659 .0
660 .remove(&entity)
661 .unwrap_or_default()
662 })
663 .collect()
664 }
665
666 #[test]
673 fn default_policy_two_flows_share_one_world_global() {
674 let mut app = App::new();
675 app.add_plugins(bevy_asset::AssetPlugin::default());
676 app.add_plugins(crate::BrinkPlugin::<()>::default());
677 install_text_accumulator(&mut app);
678
679 let (program, tables, ctx) = compile_test_story(
680 "VAR counter = 0\n~ counter = counter + 1\nCounter is {counter}.\n-> DONE\n",
681 );
682 let story = add_story_assets(&mut app, program, tables, ctx);
683
684 app.world_mut().spawn(
685 BrinkFlowRequest::<()>::builder()
686 .story(story.clone())
687 .build(),
688 );
689 app.world_mut()
690 .spawn(BrinkFlowRequest::<()>::builder().story(story).build());
691 app.update(); let mut texts = drive_all_active_via_advance_until_terminal(&mut app); texts.sort();
695 assert_eq!(
696 texts,
697 vec!["Counter is 1.\n".to_string(), "Counter is 2.\n".to_string()],
698 "two flows sharing the default (all-World) policy should observe \
699 cumulative shared state, not independent per-flow copies; got {texts:?}"
700 );
701 }
702
703 #[test]
710 fn local_knot_override_isolates_visit_state_while_world_var_stays_shared() {
711 let mut app = App::new();
712 app.add_plugins(bevy_asset::AssetPlugin::default());
713 let mut policy = WorldPolicy::default();
714 policy.overrides.insert("start".to_string(), Scope::Local);
715 app.add_plugins(crate::BrinkPlugin::<()>::default().with_policy(policy));
716 install_text_accumulator(&mut app);
717
718 let (program, tables, ctx) = compile_test_story(
724 "VAR shared_visits = 0\n-> start\n=== start ===\n\
725 ~ shared_visits = shared_visits + 1\n\
726 {start: Hello|Welcome back} (shared {shared_visits}).\n-> DONE\n",
727 );
728 let story = add_story_assets(&mut app, program, tables, ctx);
729
730 app.world_mut().spawn(
734 BrinkFlowRequest::<()>::builder()
735 .story(story.clone())
736 .build(),
737 );
738 app.update(); let flow1_text = drive_all_active_via_advance_until_terminal(&mut app) .remove(0);
741 assert!(
742 flow1_text.contains("Hello"),
743 "flow 1's first-ever visit to a Local-scoped knot should take \
744 the sequence's first branch; got {flow1_text:?}"
745 );
746 assert!(
747 flow1_text.contains("shared 1"),
748 "the World-scoped VAR should count flow 1's visit; got {flow1_text:?}"
749 );
750
751 app.world_mut()
756 .spawn(BrinkFlowRequest::<()>::builder().story(story).build());
757 app.update(); let flow2_text = drive_all_active_via_advance_until_terminal(&mut app) .remove(0);
760 assert!(
761 flow2_text.contains("Hello"),
762 "flow 2's own Local visit count should also start fresh, \
763 independent of flow 1's; got {flow2_text:?}"
764 );
765 assert!(
766 flow2_text.contains("shared 2"),
767 "the World-scoped VAR should keep counting across flows \
768 (flow 1's 1, then flow 2's 2); got {flow2_text:?}"
769 );
770 }
771
772 #[test]
778 fn unknown_policy_override_surfaces_as_fulfillment_error_not_panic() {
779 let mut app = App::new();
780 app.add_plugins(bevy_asset::AssetPlugin::default());
781 let mut policy = WorldPolicy::default();
782 policy
783 .overrides
784 .insert("does_not_exist".to_string(), Scope::Local);
785 app.add_plugins(crate::BrinkPlugin::<()>::default().with_policy(policy));
786
787 let (program, tables, ctx) = compile_test_story("Hello.\n-> END\n");
788 let story = add_story_assets(&mut app, program, tables, ctx);
789 let entity = app
790 .world_mut()
791 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
792 .id();
793
794 app.update();
796
797 assert!(
798 !app.world().entity(entity).contains::<BrinkFlow<()>>(),
799 "flow should not materialize when the policy fails to resolve"
800 );
801 assert!(
802 !app.world()
803 .entity(entity)
804 .contains::<BrinkFlowRequest<()>>(),
805 "the invalid request should be removed, not left pending forever"
806 );
807 assert!(
808 !app.world().contains_resource::<BrinkGlobals<()>>(),
809 "BrinkGlobals must never be created from a policy that fails to resolve"
810 );
811 }
812
813 use crate::globals::{load_flow_state, save_flow_state};
825 use brink_format::Value;
826 use brink_runtime::ContextAccess;
827
828 const SAVE_TEST_SRC: &str = "VAR shared_count = 0\nVAR mood = 0\n-> greet\n\
836 === greet ===\n\
837 ~ mood = mood + 1\n\
838 ~ shared_count = shared_count + 1\n\
839 Greeting mood={mood} visits={READ_COUNT(-> greet)} shared={shared_count}\n\
840 * [Again] -> greet\n\
841 * [Done] -> END\n";
842
843 fn save_test_policy() -> WorldPolicy {
846 let mut policy = WorldPolicy::default();
847 policy.overrides.insert("mood".to_string(), Scope::Local);
848 policy.overrides.insert("greet".to_string(), Scope::Local);
849 policy
850 }
851
852 type FlowQuery = SystemState<(
859 Query<
860 'static,
861 'static,
862 (
863 &'static mut BrinkFlow<()>,
864 &'static mut BrinkContext<()>,
865 &'static crate::BrinkProgram<()>,
866 &'static crate::BrinkLocale<()>,
867 ),
868 >,
869 ResMut<'static, BrinkGlobals<()>>,
870 Res<'static, Assets<ProgramAsset>>,
871 Res<'static, Assets<crate::LineTablesAsset>>,
872 Commands<'static, 'static>,
873 )>;
874
875 fn drive_entity(app: &mut App, entity: Entity) -> (String, Step) {
888 let mut state: FlowQuery = SystemState::new(app.world_mut());
889 let (mut flows, mut globals, programs, tables, mut commands) =
890 state.get_mut(app.world_mut()).expect("system params");
891 let (mut flow, mut ctx, prog, loc) = flows.get_mut(entity).expect("flow components");
892 let program = &programs.get(&prog.handle).expect("program asset").program;
893 let line_tables = &tables.get(&loc.handle).expect("line tables asset").tables;
894 let mut view = flow_context_view(&mut globals, &mut ctx);
895 let advance = flow
896 .advance_until_terminal(
897 program,
898 line_tables,
899 &mut view,
900 &brink_runtime::FallbackHandler,
901 entity,
902 &mut commands,
903 )
904 .expect("advance");
905 state.apply(app.world_mut());
906 let step = match advance {
907 Advance::Step(step) => step,
908 Advance::AwaitingQuery => unreachable!("unexpected pending external in F6.3 tests"),
911 };
912 let text = app
913 .world_mut()
914 .resource_mut::<PendingText>()
915 .0
916 .remove(&entity)
917 .unwrap_or_default();
918 (text, step)
919 }
920
921 fn choose_entity(app: &mut App, entity: Entity, index: usize) {
923 let mut state: FlowQuery = SystemState::new(app.world_mut());
924 let (mut flows, mut globals, _programs, _tables, _commands) =
925 state.get_mut(app.world_mut()).expect("system params");
926 let (mut flow, mut ctx, _prog, _loc) = flows.get_mut(entity).expect("flow components");
927 let mut view = flow_context_view(&mut globals, &mut ctx);
928 flow.choose(&mut view, index).expect("choose");
929 state.apply(app.world_mut());
930 }
931
932 fn spawn_fulfilled(app: &mut App, story: &Handle<BrinkStoryAsset>, start: FlowStart) -> Entity {
935 let entity = app
936 .world_mut()
937 .spawn(
938 BrinkFlowRequest::<()>::builder()
939 .story(story.clone())
940 .start(start)
941 .build(),
942 )
943 .id();
944 app.update();
945 entity
946 }
947
948 fn app_with_save_policy() -> App {
950 let mut app = App::new();
951 app.add_plugins(bevy_asset::AssetPlugin::default());
952 app.add_plugins(crate::BrinkPlugin::<()>::default().with_policy(save_test_policy()));
953 install_text_accumulator(&mut app);
954 app
955 }
956
957 #[test]
967 #[expect(
968 clippy::similar_names,
969 reason = "the paired a/b entity naming is the point of the test"
970 )]
971 fn full_roundtrip_world_plus_two_entities() {
972 let mut app1 = app_with_save_policy();
974 let (program1, tables1, ctx1) = compile_test_story(SAVE_TEST_SRC);
975 let story1 = add_story_assets(&mut app1, program1, tables1, ctx1);
976
977 let entity_a = spawn_fulfilled(&mut app1, &story1, FlowStart::Root);
978 let entity_b = spawn_fulfilled(&mut app1, &story1, FlowStart::Root);
979
980 let (text_a, _) = drive_entity(&mut app1, entity_a);
982 assert!(
983 text_a.contains("mood=1") && text_a.contains("visits=1"),
984 "flow A's first pass; got {text_a:?}"
985 );
986
987 let (text_b1, _) = drive_entity(&mut app1, entity_b);
989 assert!(
990 text_b1.contains("mood=1") && text_b1.contains("visits=1"),
991 "flow B's first pass; got {text_b1:?}"
992 );
993 choose_entity(&mut app1, entity_b, 0); let (text_b2, _) = drive_entity(&mut app1, entity_b);
995 assert!(
996 text_b2.contains("mood=2") && text_b2.contains("visits=2"),
997 "flow B's second pass; got {text_b2:?}"
998 );
999
1000 let (world_save, save_a, save_b) = {
1003 let mut state: FlowQuery = SystemState::new(app1.world_mut());
1004 let (mut flows, mut globals, programs, _tables, _commands) =
1005 state.get_mut(app1.world_mut()).expect("system params");
1006
1007 let handle = flows.get(entity_a).expect("flow a").2.handle.clone();
1008 let program = &programs.get(&handle).expect("program asset").program;
1009
1010 let world_save = globals.save_state(program);
1011
1012 let (_flow_a, mut ctx_a, _p, _l) = flows.get_mut(entity_a).expect("flow a");
1013 let save_a = save_flow_state(&mut globals, &mut ctx_a, program);
1014
1015 let (_flow_b, mut ctx_b, _p, _l) = flows.get_mut(entity_b).expect("flow b");
1016 let save_b = save_flow_state(&mut globals, &mut ctx_b, program);
1017
1018 (world_save, save_a, save_b)
1019 };
1020
1021 assert_eq!(save_a.globals.get("mood"), Some(&Value::Int(1)));
1023 assert_eq!(save_b.globals.get("mood"), Some(&Value::Int(2)));
1024 assert_eq!(save_a.globals.get("shared_count"), Some(&Value::Int(3)));
1028 assert_eq!(save_b.globals.get("shared_count"), Some(&Value::Int(3)));
1029 assert_eq!(world_save.globals.get("shared_count"), Some(&Value::Int(3)));
1030
1031 let mut app2 = app_with_save_policy();
1034 let (program2, tables2, ctx2) = compile_test_story(SAVE_TEST_SRC);
1035 let story2 = add_story_assets(&mut app2, program2, tables2, ctx2);
1036
1037 let entity_a2 =
1038 spawn_fulfilled(&mut app2, &story2, FlowStart::Address("greet".to_string()));
1039 let entity_b2 =
1040 spawn_fulfilled(&mut app2, &story2, FlowStart::Address("greet".to_string()));
1041
1042 {
1044 let mut state: FlowQuery = SystemState::new(app2.world_mut());
1045 let (mut flows, mut globals, programs, _tables, _commands) =
1046 state.get_mut(app2.world_mut()).expect("system params");
1047
1048 let handle = flows.get(entity_a2).expect("flow a2").2.handle.clone();
1049 let program = &programs.get(&handle).expect("program asset").program;
1050
1051 let world_report = globals.load_state(program, &world_save);
1052 assert!(
1053 world_report.is_clean(),
1054 "world load should be clean: {world_report:?}"
1055 );
1056
1057 let (_flow, mut ctx_a2, _p, _l) = flows.get_mut(entity_a2).expect("flow a2");
1058 let report_a = load_flow_state(&mut globals, &mut ctx_a2, program, &save_a);
1059 assert!(
1060 report_a.is_clean(),
1061 "entity A load should be clean: {report_a:?}"
1062 );
1063
1064 let (_flow, mut ctx_b2, _p, _l) = flows.get_mut(entity_b2).expect("flow b2");
1065 let report_b = load_flow_state(&mut globals, &mut ctx_b2, program, &save_b);
1066 assert!(
1067 report_b.is_clean(),
1068 "entity B load should be clean: {report_b:?}"
1069 );
1070 }
1071
1072 let (resumed_a_text, _) = drive_entity(&mut app2, entity_a2);
1078 assert!(
1079 resumed_a_text.contains("mood=2") && resumed_a_text.contains("visits=1"),
1080 "flow A2 should resume from its own restored state (mood 1->2, \
1081 greet visits still 1, unbumped by address-entry); got {resumed_a_text:?}",
1082 );
1083 let (resumed_b_text, _) = drive_entity(&mut app2, entity_b2);
1084 assert!(
1085 resumed_b_text.contains("mood=3") && resumed_b_text.contains("visits=2"),
1086 "flow B2 should resume from ITS OWN restored state (mood 2->3, \
1087 greet visits still 2) — distinct from flow A2's; got {resumed_b_text:?}",
1088 );
1089 }
1090
1091 #[test]
1099 #[expect(
1100 clippy::too_many_lines,
1101 reason = "one scope-routing scenario checked from three vantage points"
1102 )]
1103 fn entity_load_routes_local_to_flow_local_and_world_stays_shared() {
1104 let mut app = app_with_save_policy();
1105 let (program, tables, ctx) = compile_test_story(SAVE_TEST_SRC);
1106 let story = add_story_assets(&mut app, program, tables, ctx);
1107 let entity = spawn_fulfilled(&mut app, &story, FlowStart::Address("greet".to_string()));
1108 let other = spawn_fulfilled(&mut app, &story, FlowStart::Address("greet".to_string()));
1111
1112 let mut save = brink_runtime::SaveState {
1114 version: brink_runtime::SAVE_FORMAT_VERSION,
1115 globals: std::collections::BTreeMap::new(),
1116 global_ids: std::collections::BTreeMap::new(),
1117 visits: Vec::new(),
1118 turns: Vec::new(),
1119 turn_index: 0,
1120 rng_seed: 0,
1121 previous_random: 0,
1122 suspended: None,
1123 };
1124 save.globals.insert("mood".to_string(), Value::Int(42));
1125 save.globals
1126 .insert("shared_count".to_string(), Value::Int(7));
1127
1128 {
1129 let mut state: FlowQuery = SystemState::new(app.world_mut());
1130 let (mut flows, mut globals, programs, _tables, _commands) =
1131 state.get_mut(app.world_mut()).expect("system params");
1132 let handle = flows.get(entity).expect("flow").2.handle.clone();
1133 let program = &programs.get(&handle).expect("program asset").program;
1134 let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
1135 let report = load_flow_state(&mut globals, &mut ctx, program, &save);
1136 assert!(report.is_clean(), "load should be clean: {report:?}");
1137 }
1138
1139 let mood_idx = {
1140 let programs = app.world().resource::<Assets<ProgramAsset>>();
1141 let handle = app
1142 .world()
1143 .entity(entity)
1144 .get::<crate::BrinkProgram<()>>()
1145 .expect("BrinkProgram")
1146 .handle
1147 .clone();
1148 programs
1149 .get(&handle)
1150 .expect("program asset")
1151 .program
1152 .global_index("mood")
1153 .expect("mood global")
1154 };
1155 let shared_idx = {
1156 let programs = app.world().resource::<Assets<ProgramAsset>>();
1157 let handle = app
1158 .world()
1159 .entity(entity)
1160 .get::<crate::BrinkProgram<()>>()
1161 .expect("BrinkProgram")
1162 .handle
1163 .clone();
1164 programs
1165 .get(&handle)
1166 .expect("program asset")
1167 .program
1168 .global_index("shared_count")
1169 .expect("shared_count global")
1170 };
1171
1172 {
1174 let mut state: FlowQuery = SystemState::new(app.world_mut());
1175 let (mut flows, mut globals, _programs, _tables, _commands) =
1176 state.get_mut(app.world_mut()).expect("system params");
1177 let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
1178 let view = flow_context_view(&mut globals, &mut ctx);
1179 assert_eq!(
1180 view.global(mood_idx),
1181 &Value::Int(42),
1182 "the loaded entity's own view should see the restored Local mood"
1183 );
1184 assert_eq!(
1185 view.global(shared_idx),
1186 &Value::Int(7),
1187 "the loaded entity's own view should see the restored World shared_count"
1188 );
1189 }
1190
1191 {
1193 let globals = app.world().resource::<BrinkGlobals<()>>();
1194 assert_ne!(
1195 globals.inner.global(mood_idx),
1196 &Value::Int(42),
1197 "Local-scoped mood must NOT have been written into the shared World"
1198 );
1199 assert_eq!(
1200 globals.inner.global(shared_idx),
1201 &Value::Int(7),
1202 "World-scoped shared_count should have rewritten the shared World directly"
1203 );
1204 }
1205
1206 {
1211 let mut state: FlowQuery = SystemState::new(app.world_mut());
1212 let (mut flows, mut globals, _programs, _tables, _commands) =
1213 state.get_mut(app.world_mut()).expect("system params");
1214 let (_flow, mut ctx, _p, _l) = flows.get_mut(other).expect("other flow");
1215 let view = flow_context_view(&mut globals, &mut ctx);
1216 assert_ne!(
1217 view.global(mood_idx),
1218 &Value::Int(42),
1219 "a different flow must not see another entity's private mood"
1220 );
1221 assert_eq!(
1222 view.global(shared_idx),
1223 &Value::Int(7),
1224 "a different flow should see the same shared shared_count"
1225 );
1226 }
1227 }
1228
1229 #[test]
1233 fn load_report_surfaces_unknown_globals() {
1234 let mut app = app_with_save_policy();
1235 let (program, tables, ctx) = compile_test_story(SAVE_TEST_SRC);
1236 let story = add_story_assets(&mut app, program, tables, ctx);
1237 let entity = spawn_fulfilled(&mut app, &story, FlowStart::Address("greet".to_string()));
1238
1239 let mut save = brink_runtime::SaveState {
1240 version: brink_runtime::SAVE_FORMAT_VERSION,
1241 globals: std::collections::BTreeMap::new(),
1242 global_ids: std::collections::BTreeMap::new(),
1243 visits: Vec::new(),
1244 turns: Vec::new(),
1245 turn_index: 0,
1246 rng_seed: 0,
1247 previous_random: 0,
1248 suspended: None,
1249 };
1250 save.globals.insert("mood".to_string(), Value::Int(5));
1251 save.globals
1252 .insert("does_not_exist".to_string(), Value::Int(99));
1253
1254 let (report, mood_idx) = {
1255 let mut state: FlowQuery = SystemState::new(app.world_mut());
1256 let (mut flows, mut globals, programs, _tables, _commands) =
1257 state.get_mut(app.world_mut()).expect("system params");
1258 let handle = flows.get(entity).expect("flow").2.handle.clone();
1259 let program = &programs.get(&handle).expect("program asset").program;
1260 let mood_idx = program.global_index("mood").expect("mood global");
1261 let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
1262 let report = load_flow_state(&mut globals, &mut ctx, program, &save);
1263 (report, mood_idx)
1264 };
1265
1266 assert!(!report.is_clean(), "report should not be clean: {report:?}");
1267 assert_eq!(report.unknown_globals, vec!["does_not_exist".to_string()]);
1268
1269 let mut state: FlowQuery = SystemState::new(app.world_mut());
1271 let (mut flows, mut globals, _programs, _tables, _commands) =
1272 state.get_mut(app.world_mut()).expect("system params");
1273 let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
1274 let view = flow_context_view(&mut globals, &mut ctx);
1275 assert_eq!(
1276 view.global(mood_idx),
1277 &Value::Int(5),
1278 "the known global should still apply even though another was unknown"
1279 );
1280 }
1281
1282 #[test]
1293 fn per_marker_capability_gate_admits_one_marker_and_rejects_another() {
1294 use bevy_asset::AssetPlugin;
1295 use bevy_ecs::component::Component;
1296
1297 use crate::asset::{LineTablesAsset, fresh_context};
1298 use crate::capability::{
1299 BrinkCapabilityAppExt as _, CapabilityEffects, CapabilityManifest,
1300 CapabilityManifestExternal,
1301 };
1302
1303 #[derive(Component)]
1304 struct Transform;
1305
1306 struct MarkerHasCapability;
1307 struct MarkerMissingCapability;
1308
1309 let mut app = App::new();
1310 app.add_plugins(AssetPlugin::default());
1311 app.add_plugins(crate::BrinkPlugin::<MarkerHasCapability>::default());
1312 app.add_plugins(crate::BrinkPlugin::<MarkerMissingCapability>::default());
1313
1314 app.register_capability::<MarkerHasCapability, Transform>("Transform");
1316
1317 let mut manifest = CapabilityManifest::default();
1320 manifest.externals.push(CapabilityManifestExternal {
1321 name: "get_position".to_string(),
1322 effects: CapabilityEffects {
1323 reads: vec!["Transform".to_string()],
1324 writes: vec![],
1325 detect: std::collections::BTreeMap::new(),
1326 },
1327 });
1328 app.insert_resource(manifest);
1329
1330 let source = "EXTERNAL get_position(id)\n=== start ===\n\
1331 ~ temp x = get_position(0)\nHello.\n-> END\n";
1332 let out = brink_compiler::compile("t.ink", move |p| {
1333 if p == "t.ink" {
1334 Ok(source.to_string())
1335 } else {
1336 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1337 }
1338 })
1339 .expect("compile");
1340 let mut inkb = Vec::new();
1341 brink_format::write_inkb(&out.data, &mut inkb);
1342 let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
1343 let (program, tables) = brink_runtime::link(&loaded).expect("link");
1344 let initial_context = fresh_context(&program);
1345
1346 let world = app.world_mut();
1347 let program_handle = world
1348 .resource_mut::<Assets<ProgramAsset>>()
1349 .add(ProgramAsset {
1350 program,
1351 initial_context,
1352 effect_rows: loaded.effect_rows,
1353 });
1354 let tables_handle = world
1355 .resource_mut::<Assets<LineTablesAsset>>()
1356 .add(LineTablesAsset { tables });
1357 let story_handle = world
1358 .resource_mut::<Assets<BrinkStoryAsset>>()
1359 .add(BrinkStoryAsset {
1360 program: program_handle,
1361 line_tables: tables_handle,
1362 });
1363
1364 let entity_has = app
1365 .world_mut()
1366 .spawn(
1367 BrinkFlowRequest::<MarkerHasCapability>::builder()
1368 .story(story_handle.clone())
1369 .build(),
1370 )
1371 .id();
1372 let entity_missing = app
1373 .world_mut()
1374 .spawn(
1375 BrinkFlowRequest::<MarkerMissingCapability>::builder()
1376 .story(story_handle)
1377 .build(),
1378 )
1379 .id();
1380
1381 app.update();
1382
1383 let world = app.world();
1384 assert!(
1385 world
1386 .entity(entity_has)
1387 .contains::<BrinkFlow<MarkerHasCapability>>(),
1388 "marker with Transform registered should load successfully"
1389 );
1390 assert!(
1391 !world
1392 .entity(entity_has)
1393 .contains::<BrinkFlowRequest<MarkerHasCapability>>(),
1394 "fulfilled request should be removed"
1395 );
1396
1397 assert!(
1398 !world
1399 .entity(entity_missing)
1400 .contains::<BrinkFlow<MarkerMissingCapability>>(),
1401 "marker missing Transform must not get a flow — the load must be rejected"
1402 );
1403 assert!(
1404 !world
1405 .entity(entity_missing)
1406 .contains::<BrinkFlowRequest<MarkerMissingCapability>>(),
1407 "the rejected request must be removed too, not left pending forever"
1408 );
1409 }
1410
1411 #[test]
1418 fn single_marker_path_with_no_manifest_is_unaffected_by_the_gate() {
1419 let mut app = make_test_app();
1420 let (program, tables, ctx) =
1421 compile_test_story("=== start ===\nhello\n* [Continue] -> END\n");
1422 let story = add_story_assets(&mut app, program, tables, ctx);
1423
1424 let entity = app
1425 .world_mut()
1426 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1427 .id();
1428
1429 app.update();
1430
1431 assert!(
1432 app.world().entity(entity).contains::<BrinkFlow<()>>(),
1433 "no manifest means no required capabilities, so fulfillment proceeds as before"
1434 );
1435 }
1436}