use std::any::TypeId;
use std::collections::{BTreeMap, BTreeSet};
use std::marker::PhantomData;
use bevy_app::{App, Update};
use bevy_asset::{AssetEvent, AssetId, Assets};
use bevy_ecs::component::{Component, ComponentId};
use bevy_ecs::message::MessageReader;
use bevy_ecs::query::{Access, Changed};
use bevy_ecs::resource::Resource;
use bevy_ecs::schedule::IntoScheduleConfigs as _;
use bevy_ecs::schedule::common_conditions::any_with_component;
use bevy_ecs::system::{Query, Res, ResMut};
use bevy_log::error;
use brink_format::{CallAtom, DefinitionId, DirectEffects, EffectRowEntry};
use brink_runtime::Program;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::asset::ProgramAsset;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityEffects {
#[serde(default)]
pub reads: Vec<String>,
#[serde(default)]
pub writes: Vec<String>,
#[serde(default)]
pub detect: BTreeMap<String, bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityManifestExternal {
pub name: String,
#[serde(default)]
pub effects: CapabilityEffects,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Resource, Serialize, Deserialize)]
pub struct CapabilityManifest {
#[serde(default)]
pub externals: Vec<CapabilityManifestExternal>,
}
impl CapabilityManifest {
pub fn from_json(json: &str) -> Result<Self, CapabilityError> {
Ok(serde_json::from_str(json)?)
}
#[must_use]
pub fn external(&self, name: &str) -> Option<&CapabilityManifestExternal> {
self.externals.iter().find(|e| e.name == name)
}
}
#[derive(Debug, Error)]
pub enum CapabilityError {
#[error("capability manifest JSON is malformed: {0}")]
ManifestJson(#[from] serde_json::Error),
#[error(
"external `{external}` declares capability `{capability}` in its effects manifest, \
but no `register_capability::<_, _>(\"{capability}\")` call has registered that name \
— capability join cannot proceed for this story"
)]
UnknownCapability {
external: String,
capability: String,
},
#[error(
"story `{story}` failed to load under marker `{marker}`: this marker's \
CapabilityRegistry is missing {} manifest-required capability name(s): {}",
missing.len(),
missing
.iter()
.map(|m| format!("`{}` (required by external `{}`)", m.capability, m.external))
.collect::<Vec<_>>()
.join(", ")
)]
LoadRejected {
marker: &'static str,
story: String,
missing: Vec<MissingCapability>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct MissingCapability {
pub external: String,
pub capability: String,
}
#[derive(Resource)]
pub struct CapabilityRegistry<M: Send + Sync + 'static = ()> {
names: BTreeMap<&'static str, ComponentId>,
type_ids: BTreeMap<&'static str, TypeId>,
detect_wired: BTreeSet<TypeId>,
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> Default for CapabilityRegistry<M> {
fn default() -> Self {
Self {
names: BTreeMap::new(),
type_ids: BTreeMap::new(),
detect_wired: BTreeSet::new(),
_marker: PhantomData,
}
}
}
impl<M: Send + Sync + 'static> CapabilityRegistry<M> {
#[must_use]
pub fn component_id(&self, name: &str) -> Option<ComponentId> {
self.names.get(name).copied()
}
#[must_use]
pub fn type_id(&self, name: &str) -> Option<TypeId> {
self.type_ids.get(name).copied()
}
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
self.names.keys().copied()
}
}
pub trait BrinkCapabilityAppExt {
fn register_capability<M: Send + Sync + 'static, C: Component>(
&mut self,
name: &'static str,
) -> &mut Self;
}
impl BrinkCapabilityAppExt for App {
fn register_capability<M: Send + Sync + 'static, C: Component>(
&mut self,
name: &'static str,
) -> &mut Self {
let id = self.world_mut().register_component::<C>();
self.world_mut()
.get_resource_or_insert_with(CapabilityRegistry::<M>::default);
let needs_detect_system = {
let mut registry = self.world_mut().resource_mut::<CapabilityRegistry<M>>();
registry.names.insert(name, id);
registry.type_ids.insert(name, TypeId::of::<C>());
registry.detect_wired.insert(TypeId::of::<C>())
};
self.init_resource::<CapabilityChanges<M>>();
if needs_detect_system {
self.add_systems(
Update,
detect_capability_changes::<M, C>
.before(crate::sleep::mark_wake_dirty::<M>)
.run_if(any_with_component::<crate::sleep::FlowSleep<M>>),
);
}
self
}
}
#[derive(Resource)]
pub struct CapabilityChanges<M: Send + Sync + 'static = ()> {
changed: BTreeMap<TypeId, bool>,
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> Default for CapabilityChanges<M> {
fn default() -> Self {
Self {
changed: BTreeMap::new(),
_marker: PhantomData,
}
}
}
impl<M: Send + Sync + 'static> CapabilityChanges<M> {
#[must_use]
pub fn changed(&self, ty: TypeId) -> Option<bool> {
self.changed.get(&ty).copied()
}
}
pub fn detect_capability_changes<M: Send + Sync + 'static, C: Component>(
changed: Query<(), Changed<C>>,
mut sink: ResMut<CapabilityChanges<M>>,
) {
let any_changed = !changed.is_empty();
sink.changed.insert(TypeId::of::<C>(), any_changed);
}
pub type ContainerAccessTable = BTreeMap<DefinitionId, ContainerAccess>;
#[derive(Debug, Clone, Default)]
pub struct ContainerAccess {
pub access: Access,
pub reads: Vec<String>,
pub writes: Vec<String>,
pub detect: BTreeMap<String, bool>,
pub opaque: bool,
}
#[derive(Default)]
struct JoinAccumulator {
access: Access,
reads: BTreeSet<String>,
writes: BTreeSet<String>,
detect: BTreeMap<String, bool>,
opaque: bool,
}
impl JoinAccumulator {
fn into_container_access(self) -> ContainerAccess {
ContainerAccess {
access: self.access,
reads: self.reads.into_iter().collect(),
writes: self.writes.into_iter().collect(),
detect: self.detect,
opaque: self.opaque,
}
}
}
fn join_direct<M: Send + Sync + 'static>(
direct: &DirectEffects,
program: &Program,
manifest: &CapabilityManifest,
registry: &CapabilityRegistry<M>,
acc: &mut JoinAccumulator,
) -> Result<(), CapabilityError> {
if direct.opaque {
acc.opaque = true;
acc.access.read_all();
acc.access.write_all();
}
for call in &direct.calls {
resolve_call_atom(call, program, manifest, registry, acc)?;
}
Ok(())
}
fn resolve_call_atom<M: Send + Sync + 'static>(
call: &CallAtom,
program: &Program,
manifest: &CapabilityManifest,
registry: &CapabilityRegistry<M>,
acc: &mut JoinAccumulator,
) -> Result<(), CapabilityError> {
let Some(external_name) = program.name_checked(call.name) else {
return Ok(());
};
let Some(external) = manifest.external(external_name) else {
return Ok(());
};
for name in &external.effects.reads {
let id = resolve_capability(registry, external_name, name)?;
acc.access.add_read(id);
acc.reads.insert(name.clone());
}
for name in &external.effects.writes {
let id = resolve_capability(registry, external_name, name)?;
acc.access.add_write(id);
acc.writes.insert(name.clone());
}
for (name, bit) in &external.effects.detect {
acc.detect
.entry(name.clone())
.and_modify(|merged| *merged = *merged && *bit)
.or_insert(*bit);
}
Ok(())
}
fn resolve_capability<M: Send + Sync + 'static>(
registry: &CapabilityRegistry<M>,
external_name: &str,
capability: &str,
) -> Result<ComponentId, CapabilityError> {
registry
.component_id(capability)
.ok_or_else(|| CapabilityError::UnknownCapability {
external: external_name.to_string(),
capability: capability.to_string(),
})
}
pub fn compute_container_access<M: Send + Sync + 'static>(
program: &Program,
effect_rows: &[EffectRowEntry],
manifest: &CapabilityManifest,
registry: &CapabilityRegistry<M>,
) -> Result<ContainerAccessTable, CapabilityError> {
let mut out = BTreeMap::new();
for row in effect_rows {
let mut acc = JoinAccumulator::default();
join_direct(&row.direct, program, manifest, registry, &mut acc)?;
for dispatch in &row.dispatches {
join_direct(&dispatch.fallback, program, manifest, registry, &mut acc)?;
}
out.insert(row.def, acc.into_container_access());
}
Ok(out)
}
fn collect_missing_from_direct<M: Send + Sync + 'static>(
direct: &DirectEffects,
program: &Program,
manifest: &CapabilityManifest,
registry: &CapabilityRegistry<M>,
missing: &mut BTreeSet<(String, String)>,
) {
for call in &direct.calls {
let Some(external_name) = program.name_checked(call.name) else {
continue;
};
let Some(external) = manifest.external(external_name) else {
continue;
};
for name in external
.effects
.reads
.iter()
.chain(external.effects.writes.iter())
{
if registry.component_id(name).is_none() {
missing.insert((external_name.to_string(), name.clone()));
}
}
}
}
#[must_use]
pub fn missing_capabilities<M: Send + Sync + 'static>(
program: &Program,
effect_rows: &[EffectRowEntry],
manifest: &CapabilityManifest,
registry: &CapabilityRegistry<M>,
) -> Vec<MissingCapability> {
let mut missing = BTreeSet::new();
for row in effect_rows {
collect_missing_from_direct(&row.direct, program, manifest, registry, &mut missing);
for dispatch in &row.dispatches {
collect_missing_from_direct(
&dispatch.fallback,
program,
manifest,
registry,
&mut missing,
);
}
}
missing
.into_iter()
.map(|(external, capability)| MissingCapability {
external,
capability,
})
.collect()
}
pub fn check_load_capability_gate<M: Send + Sync + 'static>(
program: &Program,
effect_rows: &[EffectRowEntry],
manifest: &CapabilityManifest,
registry: &CapabilityRegistry<M>,
story: String,
) -> Result<(), CapabilityError> {
let missing = missing_capabilities(program, effect_rows, manifest, registry);
if missing.is_empty() {
Ok(())
} else {
Err(CapabilityError::LoadRejected {
marker: std::any::type_name::<M>(),
story,
missing,
})
}
}
#[derive(Resource)]
pub struct CapabilityTable<M: Send + Sync + 'static = ()> {
per_story: BTreeMap<AssetId<ProgramAsset>, Result<ContainerAccessTable, CapabilityError>>,
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> Default for CapabilityTable<M> {
fn default() -> Self {
Self {
per_story: BTreeMap::new(),
_marker: PhantomData,
}
}
}
impl<M: Send + Sync + 'static> CapabilityTable<M> {
#[must_use]
pub fn get(
&self,
id: AssetId<ProgramAsset>,
) -> Option<&Result<ContainerAccessTable, CapabilityError>> {
self.per_story.get(&id)
}
#[must_use]
pub fn access_for(&self, id: AssetId<ProgramAsset>) -> Option<&ContainerAccessTable> {
self.per_story.get(&id)?.as_ref().ok()
}
#[cfg(all(test, feature = "effect-trace"))]
pub(crate) fn insert_for_test(
&mut self,
id: AssetId<ProgramAsset>,
result: Result<ContainerAccessTable, CapabilityError>,
) {
self.per_story.insert(id, result);
}
}
#[expect(
clippy::needless_pass_by_value,
reason = "bevy systems take Res/ResMut/MessageReader by value"
)]
pub fn rebuild_capability_table<M: Send + Sync + 'static>(
mut events: MessageReader<AssetEvent<ProgramAsset>>,
programs: Res<Assets<ProgramAsset>>,
manifest: Res<CapabilityManifest>,
registry: Res<CapabilityRegistry<M>>,
mut table: ResMut<CapabilityTable<M>>,
) {
for event in events.read() {
match event {
AssetEvent::Added { id }
| AssetEvent::Modified { id }
| AssetEvent::LoadedWithDependencies { id } => {
let Some(asset) = programs.get(*id) else {
continue;
};
let result = compute_container_access(
&asset.program,
&asset.effect_rows,
&manifest,
®istry,
);
if let Err(err) = &result {
error!("brink capability join failed for a loaded story: {err}");
}
table.per_story.insert(*id, result);
}
AssetEvent::Removed { id } | AssetEvent::Unused { id } => {
table.per_story.remove(id);
}
}
}
}
#[must_use]
pub fn dump_container_access(program: &Program, table: &ContainerAccessTable) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for (def, access) in table {
let label = program
.divert_target_path(*def)
.unwrap_or_else(|| format!("<{def}>"));
let opaque_tag = if access.opaque {
" OPAQUE(read_all+write_all)"
} else {
""
};
let _ = writeln!(
out,
"{label}: reads=[{}] writes=[{}]{opaque_tag}",
access.reads.join(", "),
access.writes.join(", "),
);
for (name, detect_bit) in &access.detect {
let _ = writeln!(out, " detect[{name}] = {detect_bit}");
}
}
out
}
#[cfg(test)]
mod tests {
use bevy_app::App;
use bevy_ecs::component::Component;
use brink_format::{
CallAtom, CapabilityParam, DefinitionId, DefinitionTag, DirectEffects, DispatchEntry,
EffectRowEntry,
};
use super::*;
use crate::test_support::compile_test_story;
#[derive(Component)]
struct Transform;
#[derive(Component)]
struct AudioSink;
fn atom(name: brink_format::NameId) -> CallAtom {
CallAtom {
name,
capability: CapabilityParam::Any,
handle_param: None,
}
}
#[test]
fn manifest_round_trips_the_13_2_grammar() {
let json = r#"
{
"externals": [
{
"name": "get_position",
"params": [{"name": "npc", "ty": "Handle<Npc>"}],
"effects": {
"reads": ["Transform"],
"detect": {"Transform": true}
}
}
]
}
"#;
let manifest = CapabilityManifest::from_json(json).expect("valid manifest json");
assert_eq!(manifest.externals.len(), 1);
let ext = &manifest.externals[0];
assert_eq!(ext.name, "get_position");
assert_eq!(ext.effects.reads, vec!["Transform".to_string()]);
assert!(ext.effects.writes.is_empty());
assert_eq!(ext.effects.detect.get("Transform"), Some(&true));
let serialized = serde_json::to_string(&manifest).expect("serialize back to json");
let round_tripped =
CapabilityManifest::from_json(&serialized).expect("re-parse the serialized manifest");
assert_eq!(manifest, round_tripped);
}
#[test]
fn manifest_json_ignores_unknown_fields() {
let json = r#"
{
"externals": [
{"name": "play_sfx", "kind": "effect", "doc": "plays a sound", "path": ["Audio"]}
]
}
"#;
let manifest = CapabilityManifest::from_json(json).expect("unknown fields are ignored");
assert_eq!(manifest.externals[0].name, "play_sfx");
assert_eq!(manifest.externals[0].effects, CapabilityEffects::default());
}
#[test]
fn malformed_manifest_json_is_an_error() {
let err = CapabilityManifest::from_json("not json").unwrap_err();
assert!(matches!(err, CapabilityError::ManifestJson(_)));
}
#[test]
fn register_capability_indexes_component_id_by_name() {
let mut app = App::new();
app.register_capability::<(), Transform>("Transform");
app.register_capability::<(), AudioSink>("AudioSink");
let registry = app.world().resource::<CapabilityRegistry<()>>();
assert!(registry.component_id("Transform").is_some());
assert!(registry.component_id("AudioSink").is_some());
assert_eq!(
registry.component_id("Transform"),
registry.component_id("Transform")
);
assert_ne!(
registry.component_id("Transform"),
registry.component_id("AudioSink")
);
assert_eq!(
registry.names().collect::<Vec<_>>(),
vec!["AudioSink", "Transform"]
);
}
#[test]
fn unknown_capability_name_is_a_load_time_error() {
let mut app = App::new();
app.register_capability::<(), Transform>("Transform");
let registry = app.world().resource::<CapabilityRegistry<()>>();
let mut manifest = CapabilityManifest::default();
manifest.externals.push(CapabilityManifestExternal {
name: "get_position".to_string(),
effects: CapabilityEffects {
reads: vec!["Nonexistent".to_string()],
writes: vec![],
detect: BTreeMap::new(),
},
});
let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
let (program, _tables, _ctx) = compile_test_story(source);
let name_id = program
.name_id("get_position")
.expect("interned as a call kind");
let row = EffectRowEntry {
def: DefinitionId::new(DefinitionTag::Address, 0),
is_entry: true,
direct: DirectEffects {
reads: vec![],
writes: vec![],
calls: vec![atom(name_id)],
opaque: false,
emits: false,
tags: false,
faults: false,
},
dispatches: vec![],
};
let err = compute_container_access(&program, &[row], &manifest, registry).unwrap_err();
assert!(matches!(
&err,
CapabilityError::UnknownCapability { external, capability }
if external == "get_position" && capability == "Nonexistent"
));
}
#[test]
fn known_capability_joins_into_component_access_and_names() {
let mut app = App::new();
app.register_capability::<(), Transform>("Transform");
app.register_capability::<(), AudioSink>("AudioSink");
let registry = app.world().resource::<CapabilityRegistry<()>>();
let transform_id = registry.component_id("Transform").expect("registered");
let audio_id = registry.component_id("AudioSink").expect("registered");
let mut manifest = CapabilityManifest::default();
manifest.externals.push(CapabilityManifestExternal {
name: "get_position".to_string(),
effects: CapabilityEffects {
reads: vec!["Transform".to_string()],
writes: vec![],
detect: [("Transform".to_string(), true)].into_iter().collect(),
},
});
manifest.externals.push(CapabilityManifestExternal {
name: "play_sfx".to_string(),
effects: CapabilityEffects {
reads: vec![],
writes: vec!["AudioSink".to_string()],
detect: BTreeMap::new(),
},
});
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";
let (program, _tables, _ctx) = compile_test_story(source);
let get_position = program.name_id("get_position").expect("interned");
let play_sfx = program.name_id("play_sfx").expect("interned");
let row = EffectRowEntry {
def: DefinitionId::new(DefinitionTag::Address, 0),
is_entry: true,
direct: DirectEffects {
reads: vec![],
writes: vec![],
calls: vec![atom(get_position), atom(play_sfx)],
opaque: false,
emits: false,
tags: false,
faults: false,
},
dispatches: vec![],
};
let table =
compute_container_access(&program, std::slice::from_ref(&row), &manifest, registry)
.expect("join succeeds");
let access = table.get(&row.def).expect("row's container present");
assert!(access.access.has_read(transform_id));
assert!(!access.access.has_write(transform_id));
assert!(access.access.has_write(audio_id));
assert_eq!(access.reads, vec!["Transform".to_string()]);
assert_eq!(access.writes, vec!["AudioSink".to_string()]);
assert_eq!(access.detect.get("Transform"), Some(&true));
assert!(!access.opaque);
}
#[test]
fn conflicting_detect_bits_fold_conservative_and_not_last_write_wins() {
let mut app = App::new();
app.register_capability::<(), Transform>("Transform");
let registry = app.world().resource::<CapabilityRegistry<()>>();
let mut manifest = CapabilityManifest::default();
manifest.externals.push(CapabilityManifestExternal {
name: "watch_pos".to_string(),
effects: CapabilityEffects {
reads: vec!["Transform".to_string()],
writes: vec![],
detect: [("Transform".to_string(), true)].into_iter().collect(),
},
});
manifest.externals.push(CapabilityManifestExternal {
name: "poke_pos".to_string(),
effects: CapabilityEffects {
reads: vec!["Transform".to_string()],
writes: vec![],
detect: [("Transform".to_string(), false)].into_iter().collect(),
},
});
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";
let (program, _tables, _ctx) = compile_test_story(source);
let watch = program.name_id("watch_pos").expect("interned");
let poke = program.name_id("poke_pos").expect("interned");
let row_a = EffectRowEntry {
def: DefinitionId::new(DefinitionTag::Address, 0),
is_entry: true,
direct: DirectEffects {
reads: vec![],
writes: vec![],
calls: vec![atom(watch), atom(poke)],
opaque: false,
emits: false,
tags: false,
faults: false,
},
dispatches: vec![],
};
let table_a =
compute_container_access(&program, std::slice::from_ref(&row_a), &manifest, registry)
.expect("join succeeds");
assert_eq!(
table_a[&row_a.def].detect.get("Transform"),
Some(&false),
"true-then-false must AND to false (must-poll), not last-write-wins to false-by-luck"
);
let row_b = EffectRowEntry {
def: DefinitionId::new(DefinitionTag::Address, 0),
is_entry: true,
direct: DirectEffects {
reads: vec![],
writes: vec![],
calls: vec![atom(poke), atom(watch)],
opaque: false,
emits: false,
tags: false,
faults: false,
},
dispatches: vec![],
};
let table_b =
compute_container_access(&program, std::slice::from_ref(&row_b), &manifest, registry)
.expect("join succeeds");
assert_eq!(
table_b[&row_b.def].detect.get("Transform"),
Some(&false),
"false-then-true must AND to false — the regression #913 fixes: \
last-write-wins would have left it `true` and risked a missed wake"
);
}
#[test]
fn all_detect_capable_reads_keep_the_bit_true() {
let mut app = App::new();
app.register_capability::<(), Transform>("Transform");
let registry = app.world().resource::<CapabilityRegistry<()>>();
let mut manifest = CapabilityManifest::default();
for ext in ["watch_a", "watch_b"] {
manifest.externals.push(CapabilityManifestExternal {
name: ext.to_string(),
effects: CapabilityEffects {
reads: vec!["Transform".to_string()],
writes: vec![],
detect: [("Transform".to_string(), true)].into_iter().collect(),
},
});
}
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";
let (program, _tables, _ctx) = compile_test_story(source);
let a = program.name_id("watch_a").expect("interned");
let b = program.name_id("watch_b").expect("interned");
let row = EffectRowEntry {
def: DefinitionId::new(DefinitionTag::Address, 0),
is_entry: true,
direct: DirectEffects {
reads: vec![],
writes: vec![],
calls: vec![atom(a), atom(b)],
opaque: false,
emits: false,
tags: false,
faults: false,
},
dispatches: vec![],
};
let table =
compute_container_access(&program, std::slice::from_ref(&row), &manifest, registry)
.expect("join succeeds");
assert_eq!(table[&row.def].detect.get("Transform"), Some(&true));
}
#[test]
fn opaque_row_reads_and_writes_everything() {
let registry = CapabilityRegistry::<()>::default();
let manifest = CapabilityManifest::default();
let program_source = "=== start ===\nHello.\n-> END\n";
let (program, _tables, _ctx) = compile_test_story(program_source);
let row = EffectRowEntry {
def: DefinitionId::new(DefinitionTag::Address, 0),
is_entry: true,
direct: DirectEffects {
reads: vec![],
writes: vec![],
calls: vec![],
opaque: true,
emits: false,
tags: false,
faults: false,
},
dispatches: vec![],
};
let table =
compute_container_access(&program, std::slice::from_ref(&row), &manifest, ®istry)
.expect("join succeeds");
let access = table.get(&row.def).expect("row's container present");
assert!(access.opaque);
assert!(access.access.has_read_all());
assert!(access.access.has_write_all());
}
#[test]
fn dispatch_fallback_rows_always_fold_in() {
let mut app = App::new();
app.register_capability::<(), Transform>("Transform");
let registry = app.world().resource::<CapabilityRegistry<()>>();
let transform_id = registry.component_id("Transform").expect("registered");
let mut manifest = CapabilityManifest::default();
manifest.externals.push(CapabilityManifestExternal {
name: "get_position".to_string(),
effects: CapabilityEffects {
reads: vec!["Transform".to_string()],
writes: vec![],
detect: BTreeMap::new(),
},
});
let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
let (program, _tables, _ctx) = compile_test_story(source);
let get_position = program.name_id("get_position").expect("interned");
let row = EffectRowEntry {
def: DefinitionId::new(DefinitionTag::Address, 0),
is_entry: true,
direct: DirectEffects::default(),
dispatches: vec![DispatchEntry {
cell: DefinitionId::new(DefinitionTag::Address, 1),
narrowable: true,
fallback: DirectEffects {
reads: vec![],
writes: vec![],
calls: vec![atom(get_position)],
opaque: false,
emits: false,
tags: false,
faults: false,
},
}],
};
let table =
compute_container_access(&program, std::slice::from_ref(&row), &manifest, registry)
.expect("join succeeds");
let access = table.get(&row.def).expect("row's container present");
assert!(access.access.has_read(transform_id));
assert_eq!(access.reads, vec!["Transform".to_string()]);
}
#[test]
fn dump_renders_names_and_detect_bits_deterministically() {
let mut table: ContainerAccessTable = BTreeMap::new();
let access = ContainerAccess {
reads: vec!["Transform".to_string()],
writes: vec!["AudioSink".to_string()],
detect: [("Transform".to_string(), true)].into_iter().collect(),
..ContainerAccess::default()
};
table.insert(DefinitionId::new(DefinitionTag::Address, 0), access);
let source = "=== start ===\nHello.\n-> END\n";
let (program, _tables, _ctx) = compile_test_story(source);
let rendered = dump_container_access(&program, &table);
assert!(rendered.contains("reads=[Transform]"));
assert!(rendered.contains("writes=[AudioSink]"));
assert!(rendered.contains("detect[Transform] = true"));
}
#[test]
fn wired_via_brink_plugin_rebuilds_capability_table_on_story_load() {
let mut app = crate::test_support::make_test_app();
app.register_capability::<(), 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: 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) = brink_runtime::FlowInstance::new_at_root(&program);
let program_handle =
app.world_mut()
.resource_mut::<Assets<ProgramAsset>>()
.add(ProgramAsset {
program,
initial_context,
effect_rows: loaded.effect_rows,
});
let program_id = program_handle.id();
app.update();
app.update();
let table = app.world().resource::<CapabilityTable<()>>();
let access_table = table
.access_for(program_id)
.expect("capability join ran for the loaded story off the plugin's own system");
assert_eq!(
access_table.len(),
1,
"expected exactly one container row (the story's single `start` knot): {access_table:?}"
);
let access = access_table
.values()
.next()
.expect("checked len() == 1 above");
let transform_id = app
.world()
.resource::<CapabilityRegistry<()>>()
.component_id("Transform")
.expect("Transform was registered above");
assert_eq!(
access.reads,
vec!["Transform".to_string()],
"joined reads should be exactly what get_position's manifest entry declares"
);
assert!(
access.writes.is_empty(),
"get_position's manifest entry declares no writes"
);
assert!(
access.access.has_read(transform_id),
"the joined bevy Access should carry a read on Transform's ComponentId"
);
assert!(!access.access.has_write(transform_id));
assert!(
!access.opaque,
"no call in this story hits the opaque fallback"
);
drop(program_handle);
}
#[test]
fn unloading_a_story_drops_its_capability_table_entry() {
let mut app = crate::test_support::make_test_app();
app.insert_resource(CapabilityManifest::default());
let source = "=== start ===\nHello.\n-> END\n";
let (program, _tables, initial_context) = compile_test_story(source);
let program_handle =
app.world_mut()
.resource_mut::<Assets<ProgramAsset>>()
.add(ProgramAsset {
program,
initial_context,
effect_rows: vec![],
});
let program_id = program_handle.id();
app.update();
app.update(); assert!(
app.world()
.resource::<CapabilityTable<()>>()
.get(program_id)
.is_some()
);
app.world_mut()
.resource_mut::<Assets<ProgramAsset>>()
.remove(program_id);
app.update();
app.update();
assert!(
app.world()
.resource::<CapabilityTable<()>>()
.get(program_id)
.is_none()
);
drop(program_handle);
}
#[test]
fn missing_capabilities_collects_every_gap_not_just_the_first() {
let registry = CapabilityRegistry::<()>::default();
let mut manifest = CapabilityManifest::default();
manifest.externals.push(CapabilityManifestExternal {
name: "get_position".to_string(),
effects: CapabilityEffects {
reads: vec!["Transform".to_string()],
writes: vec![],
detect: BTreeMap::new(),
},
});
manifest.externals.push(CapabilityManifestExternal {
name: "play_sfx".to_string(),
effects: CapabilityEffects {
reads: vec![],
writes: vec!["AudioSink".to_string()],
detect: BTreeMap::new(),
},
});
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";
let (program, _tables, _ctx) = compile_test_story(source);
let get_position = program.name_id("get_position").expect("interned");
let play_sfx = program.name_id("play_sfx").expect("interned");
let row = EffectRowEntry {
def: DefinitionId::new(DefinitionTag::Address, 0),
is_entry: true,
direct: DirectEffects {
reads: vec![],
writes: vec![],
calls: vec![atom(get_position), atom(play_sfx)],
opaque: false,
emits: false,
tags: false,
faults: false,
},
dispatches: vec![],
};
let missing = missing_capabilities(&program, &[row], &manifest, ®istry);
assert_eq!(
missing.len(),
2,
"both externals' missing capabilities should be reported: {missing:?}"
);
assert!(missing.contains(&MissingCapability {
external: "get_position".to_string(),
capability: "Transform".to_string(),
}));
assert!(missing.contains(&MissingCapability {
external: "play_sfx".to_string(),
capability: "AudioSink".to_string(),
}));
}
#[test]
fn missing_capabilities_is_empty_when_registry_covers_every_declared_name() {
let mut app = App::new();
app.register_capability::<(), Transform>("Transform");
let registry = app.world().resource::<CapabilityRegistry<()>>();
let mut manifest = CapabilityManifest::default();
manifest.externals.push(CapabilityManifestExternal {
name: "get_position".to_string(),
effects: CapabilityEffects {
reads: vec!["Transform".to_string()],
writes: vec![],
detect: BTreeMap::new(),
},
});
let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
let (program, _tables, _ctx) = compile_test_story(source);
let get_position = program.name_id("get_position").expect("interned");
let row = EffectRowEntry {
def: DefinitionId::new(DefinitionTag::Address, 0),
is_entry: true,
direct: DirectEffects {
reads: vec![],
writes: vec![],
calls: vec![atom(get_position)],
opaque: false,
emits: false,
tags: false,
faults: false,
},
dispatches: vec![],
};
let missing = missing_capabilities(&program, &[row], &manifest, registry);
assert!(missing.is_empty(), "got {missing:?}");
}
#[test]
fn load_rejected_error_names_marker_story_and_every_missing_capability() {
let err = CapabilityError::LoadRejected {
marker: "my_game::DreamSequence",
story: "dialogue.ink".to_string(),
missing: vec![
MissingCapability {
external: "get_position".to_string(),
capability: "Transform".to_string(),
},
MissingCapability {
external: "play_sfx".to_string(),
capability: "AudioSink".to_string(),
},
],
};
let message = err.to_string();
assert!(
message.contains("my_game::DreamSequence"),
"should name the marker: {message}"
);
assert!(
message.contains("dialogue.ink"),
"should name the story: {message}"
);
assert!(
message.contains("Transform") && message.contains("get_position"),
"should name the first missing capability and its external: {message}"
);
assert!(
message.contains("AudioSink") && message.contains("play_sfx"),
"should name the second missing capability and its external too: {message}"
);
}
}