1use std::any::TypeId;
58use std::collections::{BTreeMap, BTreeSet};
59use std::marker::PhantomData;
60
61use bevy_app::{App, Update};
62use bevy_asset::{AssetEvent, AssetId, Assets};
63use bevy_ecs::component::{Component, ComponentId};
64use bevy_ecs::message::MessageReader;
65use bevy_ecs::query::{Access, Changed};
66use bevy_ecs::resource::Resource;
67use bevy_ecs::schedule::IntoScheduleConfigs as _;
68use bevy_ecs::schedule::common_conditions::any_with_component;
69use bevy_ecs::system::{Query, Res, ResMut};
70use bevy_log::error;
71use brink_format::{CallAtom, DefinitionId, DirectEffects, EffectRowEntry};
72use brink_runtime::Program;
73use serde::{Deserialize, Serialize};
74use thiserror::Error;
75
76use crate::asset::ProgramAsset;
77
78#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
85pub struct CapabilityEffects {
86 #[serde(default)]
90 pub reads: Vec<String>,
91 #[serde(default)]
93 pub writes: Vec<String>,
94 #[serde(default)]
100 pub detect: BTreeMap<String, bool>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct CapabilityManifestExternal {
119 pub name: String,
120 #[serde(default)]
121 pub effects: CapabilityEffects,
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Eq, Resource, Serialize, Deserialize)]
130pub struct CapabilityManifest {
131 #[serde(default)]
132 pub externals: Vec<CapabilityManifestExternal>,
133}
134
135impl CapabilityManifest {
136 pub fn from_json(json: &str) -> Result<Self, CapabilityError> {
138 Ok(serde_json::from_str(json)?)
139 }
140
141 #[must_use]
144 pub fn external(&self, name: &str) -> Option<&CapabilityManifestExternal> {
145 self.externals.iter().find(|e| e.name == name)
146 }
147}
148
149#[derive(Debug, Error)]
153pub enum CapabilityError {
154 #[error("capability manifest JSON is malformed: {0}")]
155 ManifestJson(#[from] serde_json::Error),
156 #[error(
162 "external `{external}` declares capability `{capability}` in its effects manifest, \
163 but no `register_capability::<_, _>(\"{capability}\")` call has registered that name \
164 — capability join cannot proceed for this story"
165 )]
166 UnknownCapability {
167 external: String,
168 capability: String,
169 },
170 #[error(
181 "story `{story}` failed to load under marker `{marker}`: this marker's \
182 CapabilityRegistry is missing {} manifest-required capability name(s): {}",
183 missing.len(),
184 missing
185 .iter()
186 .map(|m| format!("`{}` (required by external `{}`)", m.capability, m.external))
187 .collect::<Vec<_>>()
188 .join(", ")
189 )]
190 LoadRejected {
191 marker: &'static str,
194 story: String,
197 missing: Vec<MissingCapability>,
201 },
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
208pub struct MissingCapability {
209 pub external: String,
211 pub capability: String,
213}
214
215#[derive(Resource)]
222pub struct CapabilityRegistry<M: Send + Sync + 'static = ()> {
223 names: BTreeMap<&'static str, ComponentId>,
224 type_ids: BTreeMap<&'static str, TypeId>,
231 detect_wired: BTreeSet<TypeId>,
236 _marker: PhantomData<fn() -> M>,
237}
238
239impl<M: Send + Sync + 'static> Default for CapabilityRegistry<M> {
240 fn default() -> Self {
241 Self {
242 names: BTreeMap::new(),
243 type_ids: BTreeMap::new(),
244 detect_wired: BTreeSet::new(),
245 _marker: PhantomData,
246 }
247 }
248}
249
250impl<M: Send + Sync + 'static> CapabilityRegistry<M> {
251 #[must_use]
254 pub fn component_id(&self, name: &str) -> Option<ComponentId> {
255 self.names.get(name).copied()
256 }
257
258 #[must_use]
266 pub fn type_id(&self, name: &str) -> Option<TypeId> {
267 self.type_ids.get(name).copied()
268 }
269
270 pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
272 self.names.keys().copied()
273 }
274}
275
276pub trait BrinkCapabilityAppExt {
278 fn register_capability<M: Send + Sync + 'static, C: Component>(
284 &mut self,
285 name: &'static str,
286 ) -> &mut Self;
287}
288
289impl BrinkCapabilityAppExt for App {
290 fn register_capability<M: Send + Sync + 'static, C: Component>(
291 &mut self,
292 name: &'static str,
293 ) -> &mut Self {
294 let id = self.world_mut().register_component::<C>();
295 self.world_mut()
296 .get_resource_or_insert_with(CapabilityRegistry::<M>::default);
297 let needs_detect_system = {
302 let mut registry = self.world_mut().resource_mut::<CapabilityRegistry<M>>();
303 registry.names.insert(name, id);
304 registry.type_ids.insert(name, TypeId::of::<C>());
305 registry.detect_wired.insert(TypeId::of::<C>())
306 };
307 self.init_resource::<CapabilityChanges<M>>();
311 if needs_detect_system {
312 self.add_systems(
321 Update,
322 detect_capability_changes::<M, C>
323 .before(crate::sleep::mark_wake_dirty::<M>)
324 .run_if(any_with_component::<crate::sleep::FlowSleep<M>>),
325 );
326 }
327 self
328 }
329}
330
331#[derive(Resource)]
348pub struct CapabilityChanges<M: Send + Sync + 'static = ()> {
349 changed: BTreeMap<TypeId, bool>,
354 _marker: PhantomData<fn() -> M>,
355}
356
357impl<M: Send + Sync + 'static> Default for CapabilityChanges<M> {
358 fn default() -> Self {
359 Self {
360 changed: BTreeMap::new(),
361 _marker: PhantomData,
362 }
363 }
364}
365
366impl<M: Send + Sync + 'static> CapabilityChanges<M> {
367 #[must_use]
372 pub fn changed(&self, ty: TypeId) -> Option<bool> {
373 self.changed.get(&ty).copied()
374 }
375}
376
377pub fn detect_capability_changes<M: Send + Sync + 'static, C: Component>(
391 changed: Query<(), Changed<C>>,
392 mut sink: ResMut<CapabilityChanges<M>>,
393) {
394 let any_changed = !changed.is_empty();
395 sink.changed.insert(TypeId::of::<C>(), any_changed);
396}
397
398pub type ContainerAccessTable = BTreeMap<DefinitionId, ContainerAccess>;
403
404#[derive(Debug, Clone, Default)]
408pub struct ContainerAccess {
409 pub access: Access,
414 pub reads: Vec<String>,
417 pub writes: Vec<String>,
419 pub detect: BTreeMap<String, bool>,
427 pub opaque: bool,
432}
433
434#[derive(Default)]
438struct JoinAccumulator {
439 access: Access,
440 reads: BTreeSet<String>,
441 writes: BTreeSet<String>,
442 detect: BTreeMap<String, bool>,
443 opaque: bool,
444}
445
446impl JoinAccumulator {
447 fn into_container_access(self) -> ContainerAccess {
448 ContainerAccess {
449 access: self.access,
450 reads: self.reads.into_iter().collect(),
451 writes: self.writes.into_iter().collect(),
452 detect: self.detect,
453 opaque: self.opaque,
454 }
455 }
456}
457
458fn join_direct<M: Send + Sync + 'static>(
464 direct: &DirectEffects,
465 program: &Program,
466 manifest: &CapabilityManifest,
467 registry: &CapabilityRegistry<M>,
468 acc: &mut JoinAccumulator,
469) -> Result<(), CapabilityError> {
470 if direct.opaque {
471 acc.opaque = true;
472 acc.access.read_all();
473 acc.access.write_all();
474 }
475
476 for call in &direct.calls {
477 resolve_call_atom(call, program, manifest, registry, acc)?;
478 }
479 Ok(())
480}
481
482fn resolve_call_atom<M: Send + Sync + 'static>(
487 call: &CallAtom,
488 program: &Program,
489 manifest: &CapabilityManifest,
490 registry: &CapabilityRegistry<M>,
491 acc: &mut JoinAccumulator,
492) -> Result<(), CapabilityError> {
493 let Some(external_name) = program.name_checked(call.name) else {
494 return Ok(());
495 };
496 let Some(external) = manifest.external(external_name) else {
497 return Ok(());
498 };
499 for name in &external.effects.reads {
500 let id = resolve_capability(registry, external_name, name)?;
501 acc.access.add_read(id);
502 acc.reads.insert(name.clone());
503 }
504 for name in &external.effects.writes {
505 let id = resolve_capability(registry, external_name, name)?;
506 acc.access.add_write(id);
507 acc.writes.insert(name.clone());
508 }
509 for (name, bit) in &external.effects.detect {
510 acc.detect
519 .entry(name.clone())
520 .and_modify(|merged| *merged = *merged && *bit)
521 .or_insert(*bit);
522 }
523 Ok(())
524}
525
526fn resolve_capability<M: Send + Sync + 'static>(
527 registry: &CapabilityRegistry<M>,
528 external_name: &str,
529 capability: &str,
530) -> Result<ComponentId, CapabilityError> {
531 registry
532 .component_id(capability)
533 .ok_or_else(|| CapabilityError::UnknownCapability {
534 external: external_name.to_string(),
535 capability: capability.to_string(),
536 })
537}
538
539pub fn compute_container_access<M: Send + Sync + 'static>(
547 program: &Program,
548 effect_rows: &[EffectRowEntry],
549 manifest: &CapabilityManifest,
550 registry: &CapabilityRegistry<M>,
551) -> Result<ContainerAccessTable, CapabilityError> {
552 let mut out = BTreeMap::new();
553 for row in effect_rows {
554 let mut acc = JoinAccumulator::default();
555 join_direct(&row.direct, program, manifest, registry, &mut acc)?;
556 for dispatch in &row.dispatches {
560 join_direct(&dispatch.fallback, program, manifest, registry, &mut acc)?;
561 }
562 out.insert(row.def, acc.into_container_access());
563 }
564 Ok(out)
565}
566
567fn collect_missing_from_direct<M: Send + Sync + 'static>(
571 direct: &DirectEffects,
572 program: &Program,
573 manifest: &CapabilityManifest,
574 registry: &CapabilityRegistry<M>,
575 missing: &mut BTreeSet<(String, String)>,
576) {
577 for call in &direct.calls {
578 let Some(external_name) = program.name_checked(call.name) else {
579 continue;
580 };
581 let Some(external) = manifest.external(external_name) else {
582 continue;
583 };
584 for name in external
585 .effects
586 .reads
587 .iter()
588 .chain(external.effects.writes.iter())
589 {
590 if registry.component_id(name).is_none() {
591 missing.insert((external_name.to_string(), name.clone()));
592 }
593 }
594 }
595}
596
597#[must_use]
610pub fn missing_capabilities<M: Send + Sync + 'static>(
611 program: &Program,
612 effect_rows: &[EffectRowEntry],
613 manifest: &CapabilityManifest,
614 registry: &CapabilityRegistry<M>,
615) -> Vec<MissingCapability> {
616 let mut missing = BTreeSet::new();
617 for row in effect_rows {
618 collect_missing_from_direct(&row.direct, program, manifest, registry, &mut missing);
619 for dispatch in &row.dispatches {
620 collect_missing_from_direct(
621 &dispatch.fallback,
622 program,
623 manifest,
624 registry,
625 &mut missing,
626 );
627 }
628 }
629 missing
630 .into_iter()
631 .map(|(external, capability)| MissingCapability {
632 external,
633 capability,
634 })
635 .collect()
636}
637
638pub fn check_load_capability_gate<M: Send + Sync + 'static>(
655 program: &Program,
656 effect_rows: &[EffectRowEntry],
657 manifest: &CapabilityManifest,
658 registry: &CapabilityRegistry<M>,
659 story: String,
660) -> Result<(), CapabilityError> {
661 let missing = missing_capabilities(program, effect_rows, manifest, registry);
662 if missing.is_empty() {
663 Ok(())
664 } else {
665 Err(CapabilityError::LoadRejected {
666 marker: std::any::type_name::<M>(),
667 story,
668 missing,
669 })
670 }
671}
672
673#[derive(Resource)]
681pub struct CapabilityTable<M: Send + Sync + 'static = ()> {
682 per_story: BTreeMap<AssetId<ProgramAsset>, Result<ContainerAccessTable, CapabilityError>>,
683 _marker: PhantomData<fn() -> M>,
684}
685
686impl<M: Send + Sync + 'static> Default for CapabilityTable<M> {
687 fn default() -> Self {
688 Self {
689 per_story: BTreeMap::new(),
690 _marker: PhantomData,
691 }
692 }
693}
694
695impl<M: Send + Sync + 'static> CapabilityTable<M> {
696 #[must_use]
701 pub fn get(
702 &self,
703 id: AssetId<ProgramAsset>,
704 ) -> Option<&Result<ContainerAccessTable, CapabilityError>> {
705 self.per_story.get(&id)
706 }
707
708 #[must_use]
710 pub fn access_for(&self, id: AssetId<ProgramAsset>) -> Option<&ContainerAccessTable> {
711 self.per_story.get(&id)?.as_ref().ok()
712 }
713
714 #[cfg(all(test, feature = "effect-trace"))]
721 pub(crate) fn insert_for_test(
722 &mut self,
723 id: AssetId<ProgramAsset>,
724 result: Result<ContainerAccessTable, CapabilityError>,
725 ) {
726 self.per_story.insert(id, result);
727 }
728}
729
730#[expect(
739 clippy::needless_pass_by_value,
740 reason = "bevy systems take Res/ResMut/MessageReader by value"
741)]
742pub fn rebuild_capability_table<M: Send + Sync + 'static>(
743 mut events: MessageReader<AssetEvent<ProgramAsset>>,
744 programs: Res<Assets<ProgramAsset>>,
745 manifest: Res<CapabilityManifest>,
746 registry: Res<CapabilityRegistry<M>>,
747 mut table: ResMut<CapabilityTable<M>>,
748) {
749 for event in events.read() {
750 match event {
751 AssetEvent::Added { id }
752 | AssetEvent::Modified { id }
753 | AssetEvent::LoadedWithDependencies { id } => {
754 let Some(asset) = programs.get(*id) else {
755 continue;
756 };
757 let result = compute_container_access(
758 &asset.program,
759 &asset.effect_rows,
760 &manifest,
761 ®istry,
762 );
763 if let Err(err) = &result {
764 error!("brink capability join failed for a loaded story: {err}");
765 }
766 table.per_story.insert(*id, result);
767 }
768 AssetEvent::Removed { id } | AssetEvent::Unused { id } => {
769 table.per_story.remove(id);
770 }
771 }
772 }
773}
774
775#[must_use]
780pub fn dump_container_access(program: &Program, table: &ContainerAccessTable) -> String {
781 use std::fmt::Write as _;
782
783 let mut out = String::new();
784 for (def, access) in table {
785 let label = program
786 .divert_target_path(*def)
787 .unwrap_or_else(|| format!("<{def}>"));
788 let opaque_tag = if access.opaque {
789 " OPAQUE(read_all+write_all)"
790 } else {
791 ""
792 };
793 let _ = writeln!(
794 out,
795 "{label}: reads=[{}] writes=[{}]{opaque_tag}",
796 access.reads.join(", "),
797 access.writes.join(", "),
798 );
799 for (name, detect_bit) in &access.detect {
800 let _ = writeln!(out, " detect[{name}] = {detect_bit}");
801 }
802 }
803 out
804}
805
806#[cfg(test)]
807mod tests {
808 use bevy_app::App;
809 use bevy_ecs::component::Component;
810 use brink_format::{
811 CallAtom, CapabilityParam, DefinitionId, DefinitionTag, DirectEffects, DispatchEntry,
812 EffectRowEntry,
813 };
814
815 use super::*;
816 use crate::test_support::compile_test_story;
817
818 #[derive(Component)]
819 struct Transform;
820
821 #[derive(Component)]
822 struct AudioSink;
823
824 fn atom(name: brink_format::NameId) -> CallAtom {
825 CallAtom {
826 name,
827 capability: CapabilityParam::Any,
828 handle_param: None,
829 }
830 }
831
832 #[test]
833 fn manifest_round_trips_the_13_2_grammar() {
834 let json = r#"
835 {
836 "externals": [
837 {
838 "name": "get_position",
839 "params": [{"name": "npc", "ty": "Handle<Npc>"}],
840 "effects": {
841 "reads": ["Transform"],
842 "detect": {"Transform": true}
843 }
844 }
845 ]
846 }
847 "#;
848 let manifest = CapabilityManifest::from_json(json).expect("valid manifest json");
849 assert_eq!(manifest.externals.len(), 1);
850 let ext = &manifest.externals[0];
851 assert_eq!(ext.name, "get_position");
852 assert_eq!(ext.effects.reads, vec!["Transform".to_string()]);
853 assert!(ext.effects.writes.is_empty());
854 assert_eq!(ext.effects.detect.get("Transform"), Some(&true));
855
856 let serialized = serde_json::to_string(&manifest).expect("serialize back to json");
857 let round_tripped =
858 CapabilityManifest::from_json(&serialized).expect("re-parse the serialized manifest");
859 assert_eq!(manifest, round_tripped);
860 }
861
862 #[test]
863 fn manifest_json_ignores_unknown_fields() {
864 let json = r#"
868 {
869 "externals": [
870 {"name": "play_sfx", "kind": "effect", "doc": "plays a sound", "path": ["Audio"]}
871 ]
872 }
873 "#;
874 let manifest = CapabilityManifest::from_json(json).expect("unknown fields are ignored");
875 assert_eq!(manifest.externals[0].name, "play_sfx");
876 assert_eq!(manifest.externals[0].effects, CapabilityEffects::default());
877 }
878
879 #[test]
880 fn malformed_manifest_json_is_an_error() {
881 let err = CapabilityManifest::from_json("not json").unwrap_err();
882 assert!(matches!(err, CapabilityError::ManifestJson(_)));
883 }
884
885 #[test]
886 fn register_capability_indexes_component_id_by_name() {
887 let mut app = App::new();
888 app.register_capability::<(), Transform>("Transform");
889 app.register_capability::<(), AudioSink>("AudioSink");
890
891 let registry = app.world().resource::<CapabilityRegistry<()>>();
892 assert!(registry.component_id("Transform").is_some());
893 assert!(registry.component_id("AudioSink").is_some());
894 assert_eq!(
895 registry.component_id("Transform"),
896 registry.component_id("Transform")
897 );
898 assert_ne!(
899 registry.component_id("Transform"),
900 registry.component_id("AudioSink")
901 );
902 assert_eq!(
903 registry.names().collect::<Vec<_>>(),
904 vec!["AudioSink", "Transform"]
905 );
906 }
907
908 #[test]
909 fn unknown_capability_name_is_a_load_time_error() {
910 let mut app = App::new();
911 app.register_capability::<(), Transform>("Transform");
912 let registry = app.world().resource::<CapabilityRegistry<()>>();
913
914 let mut manifest = CapabilityManifest::default();
915 manifest.externals.push(CapabilityManifestExternal {
916 name: "get_position".to_string(),
917 effects: CapabilityEffects {
918 reads: vec!["Nonexistent".to_string()],
919 writes: vec![],
920 detect: BTreeMap::new(),
921 },
922 });
923
924 let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
925 let (program, _tables, _ctx) = compile_test_story(source);
926 let name_id = program
927 .name_id("get_position")
928 .expect("interned as a call kind");
929
930 let row = EffectRowEntry {
931 def: DefinitionId::new(DefinitionTag::Address, 0),
932 is_entry: true,
933 direct: DirectEffects {
934 reads: vec![],
935 writes: vec![],
936 calls: vec![atom(name_id)],
937 opaque: false,
938 emits: false,
939 tags: false,
940 faults: false,
941 },
942 dispatches: vec![],
943 };
944
945 let err = compute_container_access(&program, &[row], &manifest, registry).unwrap_err();
946 assert!(matches!(
947 &err,
948 CapabilityError::UnknownCapability { external, capability }
949 if external == "get_position" && capability == "Nonexistent"
950 ));
951 }
952
953 #[test]
954 fn known_capability_joins_into_component_access_and_names() {
955 let mut app = App::new();
956 app.register_capability::<(), Transform>("Transform");
957 app.register_capability::<(), AudioSink>("AudioSink");
958 let registry = app.world().resource::<CapabilityRegistry<()>>();
959 let transform_id = registry.component_id("Transform").expect("registered");
960 let audio_id = registry.component_id("AudioSink").expect("registered");
961
962 let mut manifest = CapabilityManifest::default();
963 manifest.externals.push(CapabilityManifestExternal {
964 name: "get_position".to_string(),
965 effects: CapabilityEffects {
966 reads: vec!["Transform".to_string()],
967 writes: vec![],
968 detect: [("Transform".to_string(), true)].into_iter().collect(),
969 },
970 });
971 manifest.externals.push(CapabilityManifestExternal {
972 name: "play_sfx".to_string(),
973 effects: CapabilityEffects {
974 reads: vec![],
975 writes: vec!["AudioSink".to_string()],
976 detect: BTreeMap::new(),
977 },
978 });
979
980 let source = "EXTERNAL get_position(id)\nEXTERNAL play_sfx(id)\n=== start ===\n~ temp x = get_position(0)\n~ play_sfx(0)\nHello.\n-> END\n";
981 let (program, _tables, _ctx) = compile_test_story(source);
982 let get_position = program.name_id("get_position").expect("interned");
983 let play_sfx = program.name_id("play_sfx").expect("interned");
984
985 let row = EffectRowEntry {
986 def: DefinitionId::new(DefinitionTag::Address, 0),
987 is_entry: true,
988 direct: DirectEffects {
989 reads: vec![],
990 writes: vec![],
991 calls: vec![atom(get_position), atom(play_sfx)],
992 opaque: false,
993 emits: false,
994 tags: false,
995 faults: false,
996 },
997 dispatches: vec![],
998 };
999
1000 let table =
1001 compute_container_access(&program, std::slice::from_ref(&row), &manifest, registry)
1002 .expect("join succeeds");
1003 let access = table.get(&row.def).expect("row's container present");
1004 assert!(access.access.has_read(transform_id));
1005 assert!(!access.access.has_write(transform_id));
1006 assert!(access.access.has_write(audio_id));
1007 assert_eq!(access.reads, vec!["Transform".to_string()]);
1008 assert_eq!(access.writes, vec!["AudioSink".to_string()]);
1009 assert_eq!(access.detect.get("Transform"), Some(&true));
1010 assert!(!access.opaque);
1011 }
1012
1013 #[test]
1020 fn conflicting_detect_bits_fold_conservative_and_not_last_write_wins() {
1021 let mut app = App::new();
1022 app.register_capability::<(), Transform>("Transform");
1023 let registry = app.world().resource::<CapabilityRegistry<()>>();
1024
1025 let mut manifest = CapabilityManifest::default();
1029 manifest.externals.push(CapabilityManifestExternal {
1030 name: "watch_pos".to_string(),
1031 effects: CapabilityEffects {
1032 reads: vec!["Transform".to_string()],
1033 writes: vec![],
1034 detect: [("Transform".to_string(), true)].into_iter().collect(),
1035 },
1036 });
1037 manifest.externals.push(CapabilityManifestExternal {
1038 name: "poke_pos".to_string(),
1039 effects: CapabilityEffects {
1040 reads: vec!["Transform".to_string()],
1041 writes: vec![],
1042 detect: [("Transform".to_string(), false)].into_iter().collect(),
1043 },
1044 });
1045
1046 let source = "EXTERNAL watch_pos(id)\nEXTERNAL poke_pos(id)\n=== start ===\n~ temp x = watch_pos(0)\n~ temp y = poke_pos(0)\nHi.\n-> END\n";
1047 let (program, _tables, _ctx) = compile_test_story(source);
1048 let watch = program.name_id("watch_pos").expect("interned");
1049 let poke = program.name_id("poke_pos").expect("interned");
1050
1051 let row_a = EffectRowEntry {
1053 def: DefinitionId::new(DefinitionTag::Address, 0),
1054 is_entry: true,
1055 direct: DirectEffects {
1056 reads: vec![],
1057 writes: vec![],
1058 calls: vec![atom(watch), atom(poke)],
1059 opaque: false,
1060 emits: false,
1061 tags: false,
1062 faults: false,
1063 },
1064 dispatches: vec![],
1065 };
1066 let table_a =
1067 compute_container_access(&program, std::slice::from_ref(&row_a), &manifest, registry)
1068 .expect("join succeeds");
1069 assert_eq!(
1070 table_a[&row_a.def].detect.get("Transform"),
1071 Some(&false),
1072 "true-then-false must AND to false (must-poll), not last-write-wins to false-by-luck"
1073 );
1074
1075 let row_b = EffectRowEntry {
1078 def: DefinitionId::new(DefinitionTag::Address, 0),
1079 is_entry: true,
1080 direct: DirectEffects {
1081 reads: vec![],
1082 writes: vec![],
1083 calls: vec![atom(poke), atom(watch)],
1084 opaque: false,
1085 emits: false,
1086 tags: false,
1087 faults: false,
1088 },
1089 dispatches: vec![],
1090 };
1091 let table_b =
1092 compute_container_access(&program, std::slice::from_ref(&row_b), &manifest, registry)
1093 .expect("join succeeds");
1094 assert_eq!(
1095 table_b[&row_b.def].detect.get("Transform"),
1096 Some(&false),
1097 "false-then-true must AND to false — the regression #913 fixes: \
1098 last-write-wins would have left it `true` and risked a missed wake"
1099 );
1100 }
1101
1102 #[test]
1105 fn all_detect_capable_reads_keep_the_bit_true() {
1106 let mut app = App::new();
1107 app.register_capability::<(), Transform>("Transform");
1108 let registry = app.world().resource::<CapabilityRegistry<()>>();
1109
1110 let mut manifest = CapabilityManifest::default();
1111 for ext in ["watch_a", "watch_b"] {
1112 manifest.externals.push(CapabilityManifestExternal {
1113 name: ext.to_string(),
1114 effects: CapabilityEffects {
1115 reads: vec!["Transform".to_string()],
1116 writes: vec![],
1117 detect: [("Transform".to_string(), true)].into_iter().collect(),
1118 },
1119 });
1120 }
1121 let source = "EXTERNAL watch_a(id)\nEXTERNAL watch_b(id)\n=== start ===\n~ temp x = watch_a(0)\n~ temp y = watch_b(0)\nHi.\n-> END\n";
1122 let (program, _tables, _ctx) = compile_test_story(source);
1123 let a = program.name_id("watch_a").expect("interned");
1124 let b = program.name_id("watch_b").expect("interned");
1125 let row = EffectRowEntry {
1126 def: DefinitionId::new(DefinitionTag::Address, 0),
1127 is_entry: true,
1128 direct: DirectEffects {
1129 reads: vec![],
1130 writes: vec![],
1131 calls: vec![atom(a), atom(b)],
1132 opaque: false,
1133 emits: false,
1134 tags: false,
1135 faults: false,
1136 },
1137 dispatches: vec![],
1138 };
1139 let table =
1140 compute_container_access(&program, std::slice::from_ref(&row), &manifest, registry)
1141 .expect("join succeeds");
1142 assert_eq!(table[&row.def].detect.get("Transform"), Some(&true));
1143 }
1144
1145 #[test]
1146 fn opaque_row_reads_and_writes_everything() {
1147 let registry = CapabilityRegistry::<()>::default();
1148 let manifest = CapabilityManifest::default();
1149 let program_source = "=== start ===\nHello.\n-> END\n";
1150 let (program, _tables, _ctx) = compile_test_story(program_source);
1151
1152 let row = EffectRowEntry {
1153 def: DefinitionId::new(DefinitionTag::Address, 0),
1154 is_entry: true,
1155 direct: DirectEffects {
1156 reads: vec![],
1157 writes: vec![],
1158 calls: vec![],
1159 opaque: true,
1160 emits: false,
1161 tags: false,
1162 faults: false,
1163 },
1164 dispatches: vec![],
1165 };
1166
1167 let table =
1168 compute_container_access(&program, std::slice::from_ref(&row), &manifest, ®istry)
1169 .expect("join succeeds");
1170 let access = table.get(&row.def).expect("row's container present");
1171 assert!(access.opaque);
1172 assert!(access.access.has_read_all());
1173 assert!(access.access.has_write_all());
1174 }
1175
1176 #[test]
1177 fn dispatch_fallback_rows_always_fold_in() {
1178 let mut app = App::new();
1183 app.register_capability::<(), Transform>("Transform");
1184 let registry = app.world().resource::<CapabilityRegistry<()>>();
1185 let transform_id = registry.component_id("Transform").expect("registered");
1186
1187 let mut manifest = CapabilityManifest::default();
1188 manifest.externals.push(CapabilityManifestExternal {
1189 name: "get_position".to_string(),
1190 effects: CapabilityEffects {
1191 reads: vec!["Transform".to_string()],
1192 writes: vec![],
1193 detect: BTreeMap::new(),
1194 },
1195 });
1196
1197 let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
1198 let (program, _tables, _ctx) = compile_test_story(source);
1199 let get_position = program.name_id("get_position").expect("interned");
1200
1201 let row = EffectRowEntry {
1202 def: DefinitionId::new(DefinitionTag::Address, 0),
1203 is_entry: true,
1204 direct: DirectEffects::default(),
1205 dispatches: vec![DispatchEntry {
1206 cell: DefinitionId::new(DefinitionTag::Address, 1),
1207 narrowable: true,
1208 fallback: DirectEffects {
1209 reads: vec![],
1210 writes: vec![],
1211 calls: vec![atom(get_position)],
1212 opaque: false,
1213 emits: false,
1214 tags: false,
1215 faults: false,
1216 },
1217 }],
1218 };
1219
1220 let table =
1221 compute_container_access(&program, std::slice::from_ref(&row), &manifest, registry)
1222 .expect("join succeeds");
1223 let access = table.get(&row.def).expect("row's container present");
1224 assert!(access.access.has_read(transform_id));
1225 assert_eq!(access.reads, vec!["Transform".to_string()]);
1226 }
1227
1228 #[test]
1229 fn dump_renders_names_and_detect_bits_deterministically() {
1230 let mut table: ContainerAccessTable = BTreeMap::new();
1231 let access = ContainerAccess {
1232 reads: vec!["Transform".to_string()],
1233 writes: vec!["AudioSink".to_string()],
1234 detect: [("Transform".to_string(), true)].into_iter().collect(),
1235 ..ContainerAccess::default()
1236 };
1237 table.insert(DefinitionId::new(DefinitionTag::Address, 0), access);
1238
1239 let source = "=== start ===\nHello.\n-> END\n";
1240 let (program, _tables, _ctx) = compile_test_story(source);
1241 let rendered = dump_container_access(&program, &table);
1242 assert!(rendered.contains("reads=[Transform]"));
1243 assert!(rendered.contains("writes=[AudioSink]"));
1244 assert!(rendered.contains("detect[Transform] = true"));
1245 }
1246
1247 #[test]
1256 fn wired_via_brink_plugin_rebuilds_capability_table_on_story_load() {
1257 let mut app = crate::test_support::make_test_app();
1258 app.register_capability::<(), Transform>("Transform");
1259
1260 let mut manifest = CapabilityManifest::default();
1261 manifest.externals.push(CapabilityManifestExternal {
1262 name: "get_position".to_string(),
1263 effects: CapabilityEffects {
1264 reads: vec!["Transform".to_string()],
1265 writes: vec![],
1266 detect: BTreeMap::new(),
1267 },
1268 });
1269 app.insert_resource(manifest);
1270
1271 let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
1272 let out = brink_compiler::compile("t.ink", move |p| {
1273 if p == "t.ink" {
1274 Ok(source.to_string())
1275 } else {
1276 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1277 }
1278 })
1279 .expect("compile");
1280 let mut inkb = Vec::new();
1281 brink_format::write_inkb(&out.data, &mut inkb);
1282 let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
1283 let (program, _tables) = brink_runtime::link(&loaded).expect("link");
1284 let (_, initial_context) = brink_runtime::FlowInstance::new_at_root(&program);
1285
1286 let program_handle =
1290 app.world_mut()
1291 .resource_mut::<Assets<ProgramAsset>>()
1292 .add(ProgramAsset {
1293 program,
1294 initial_context,
1295 effect_rows: loaded.effect_rows,
1296 });
1297 let program_id = program_handle.id();
1298
1299 app.update();
1306 app.update();
1307
1308 let table = app.world().resource::<CapabilityTable<()>>();
1309 let access_table = table
1310 .access_for(program_id)
1311 .expect("capability join ran for the loaded story off the plugin's own system");
1312 assert_eq!(
1318 access_table.len(),
1319 1,
1320 "expected exactly one container row (the story's single `start` knot): {access_table:?}"
1321 );
1322 let access = access_table
1323 .values()
1324 .next()
1325 .expect("checked len() == 1 above");
1326 let transform_id = app
1327 .world()
1328 .resource::<CapabilityRegistry<()>>()
1329 .component_id("Transform")
1330 .expect("Transform was registered above");
1331 assert_eq!(
1332 access.reads,
1333 vec!["Transform".to_string()],
1334 "joined reads should be exactly what get_position's manifest entry declares"
1335 );
1336 assert!(
1337 access.writes.is_empty(),
1338 "get_position's manifest entry declares no writes"
1339 );
1340 assert!(
1341 access.access.has_read(transform_id),
1342 "the joined bevy Access should carry a read on Transform's ComponentId"
1343 );
1344 assert!(!access.access.has_write(transform_id));
1345 assert!(
1346 !access.opaque,
1347 "no call in this story hits the opaque fallback"
1348 );
1349 drop(program_handle);
1350 }
1351
1352 #[test]
1356 fn unloading_a_story_drops_its_capability_table_entry() {
1357 let mut app = crate::test_support::make_test_app();
1358 app.insert_resource(CapabilityManifest::default());
1359
1360 let source = "=== start ===\nHello.\n-> END\n";
1361 let (program, _tables, initial_context) = compile_test_story(source);
1362 let program_handle =
1365 app.world_mut()
1366 .resource_mut::<Assets<ProgramAsset>>()
1367 .add(ProgramAsset {
1368 program,
1369 initial_context,
1370 effect_rows: vec![],
1371 });
1372 let program_id = program_handle.id();
1373 app.update();
1374 app.update(); assert!(
1376 app.world()
1377 .resource::<CapabilityTable<()>>()
1378 .get(program_id)
1379 .is_some()
1380 );
1381
1382 app.world_mut()
1383 .resource_mut::<Assets<ProgramAsset>>()
1384 .remove(program_id);
1385 app.update();
1386 app.update();
1387 assert!(
1388 app.world()
1389 .resource::<CapabilityTable<()>>()
1390 .get(program_id)
1391 .is_none()
1392 );
1393 drop(program_handle);
1394 }
1395
1396 #[test]
1404 fn missing_capabilities_collects_every_gap_not_just_the_first() {
1405 let registry = CapabilityRegistry::<()>::default(); let mut manifest = CapabilityManifest::default();
1408 manifest.externals.push(CapabilityManifestExternal {
1409 name: "get_position".to_string(),
1410 effects: CapabilityEffects {
1411 reads: vec!["Transform".to_string()],
1412 writes: vec![],
1413 detect: BTreeMap::new(),
1414 },
1415 });
1416 manifest.externals.push(CapabilityManifestExternal {
1417 name: "play_sfx".to_string(),
1418 effects: CapabilityEffects {
1419 reads: vec![],
1420 writes: vec!["AudioSink".to_string()],
1421 detect: BTreeMap::new(),
1422 },
1423 });
1424
1425 let source = "EXTERNAL get_position(id)\nEXTERNAL play_sfx(id)\n\
1426 === start ===\n~ temp x = get_position(0)\n~ play_sfx(0)\nHello.\n-> END\n";
1427 let (program, _tables, _ctx) = compile_test_story(source);
1428 let get_position = program.name_id("get_position").expect("interned");
1429 let play_sfx = program.name_id("play_sfx").expect("interned");
1430
1431 let row = EffectRowEntry {
1432 def: DefinitionId::new(DefinitionTag::Address, 0),
1433 is_entry: true,
1434 direct: DirectEffects {
1435 reads: vec![],
1436 writes: vec![],
1437 calls: vec![atom(get_position), atom(play_sfx)],
1438 opaque: false,
1439 emits: false,
1440 tags: false,
1441 faults: false,
1442 },
1443 dispatches: vec![],
1444 };
1445
1446 let missing = missing_capabilities(&program, &[row], &manifest, ®istry);
1447 assert_eq!(
1448 missing.len(),
1449 2,
1450 "both externals' missing capabilities should be reported: {missing:?}"
1451 );
1452 assert!(missing.contains(&MissingCapability {
1453 external: "get_position".to_string(),
1454 capability: "Transform".to_string(),
1455 }));
1456 assert!(missing.contains(&MissingCapability {
1457 external: "play_sfx".to_string(),
1458 capability: "AudioSink".to_string(),
1459 }));
1460 }
1461
1462 #[test]
1466 fn missing_capabilities_is_empty_when_registry_covers_every_declared_name() {
1467 let mut app = App::new();
1468 app.register_capability::<(), Transform>("Transform");
1469 let registry = app.world().resource::<CapabilityRegistry<()>>();
1470
1471 let mut manifest = CapabilityManifest::default();
1472 manifest.externals.push(CapabilityManifestExternal {
1473 name: "get_position".to_string(),
1474 effects: CapabilityEffects {
1475 reads: vec!["Transform".to_string()],
1476 writes: vec![],
1477 detect: BTreeMap::new(),
1478 },
1479 });
1480
1481 let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
1482 let (program, _tables, _ctx) = compile_test_story(source);
1483 let get_position = program.name_id("get_position").expect("interned");
1484
1485 let row = EffectRowEntry {
1486 def: DefinitionId::new(DefinitionTag::Address, 0),
1487 is_entry: true,
1488 direct: DirectEffects {
1489 reads: vec![],
1490 writes: vec![],
1491 calls: vec![atom(get_position)],
1492 opaque: false,
1493 emits: false,
1494 tags: false,
1495 faults: false,
1496 },
1497 dispatches: vec![],
1498 };
1499
1500 let missing = missing_capabilities(&program, &[row], &manifest, registry);
1501 assert!(missing.is_empty(), "got {missing:?}");
1502 }
1503
1504 #[test]
1508 fn load_rejected_error_names_marker_story_and_every_missing_capability() {
1509 let err = CapabilityError::LoadRejected {
1510 marker: "my_game::DreamSequence",
1511 story: "dialogue.ink".to_string(),
1512 missing: vec![
1513 MissingCapability {
1514 external: "get_position".to_string(),
1515 capability: "Transform".to_string(),
1516 },
1517 MissingCapability {
1518 external: "play_sfx".to_string(),
1519 capability: "AudioSink".to_string(),
1520 },
1521 ],
1522 };
1523 let message = err.to_string();
1524 assert!(
1525 message.contains("my_game::DreamSequence"),
1526 "should name the marker: {message}"
1527 );
1528 assert!(
1529 message.contains("dialogue.ink"),
1530 "should name the story: {message}"
1531 );
1532 assert!(
1533 message.contains("Transform") && message.contains("get_position"),
1534 "should name the first missing capability and its external: {message}"
1535 );
1536 assert!(
1537 message.contains("AudioSink") && message.contains("play_sfx"),
1538 "should name the second missing capability and its external too: {message}"
1539 );
1540 }
1541}