use std::collections::{BTreeMap, BTreeSet};
use std::marker::PhantomData;
use bevy_asset::Assets;
use bevy_ecs::change_detection::{DetectChanges, DetectChangesMut};
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::query::QueryState;
use bevy_ecs::reflect::ReflectComponent;
use bevy_ecs::system::{Local, Query, Res, ResMut};
use bevy_ecs::world::World as EcsWorld;
use bevy_log::warn;
use bevy_reflect::Reflect;
use brink_format::{DefinitionId, DirectEffects, EffectRowEntry, Value};
use brink_runtime::{Program, StoryStatus};
use thiserror::Error;
use crate::asset::{BrinkProgram, ProgramAsset};
use crate::bindings::{BrinkBindings, call_ink_function, call_ink_function_value};
use crate::capability::{
CapabilityChanges, CapabilityManifest, CapabilityRegistry, ContainerAccess,
};
use crate::flow::BrinkFlow;
use crate::globals::BrinkGlobals;
use crate::wake_delta::{BrinkWorldDelta, WorldDelta};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Reflect)]
pub enum WakeArming {
#[default]
Persistent,
Once,
Latch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect)]
pub enum SleepState {
Parked,
Woken,
Cancelled,
Faulted,
}
#[derive(Debug, Clone, PartialEq, Eq, Reflect)]
pub struct DetectSummary {
pub bits: BTreeMap<String, bool>,
pub all_detect_capable: bool,
}
impl Default for DetectSummary {
fn default() -> Self {
Self::from_bits(BTreeMap::new())
}
}
impl DetectSummary {
#[must_use]
pub fn from_bits(bits: BTreeMap<String, bool>) -> Self {
let all_detect_capable = bits.values().all(|&b| b);
Self {
bits,
all_detect_capable,
}
}
#[must_use]
pub fn from_container_access(access: &ContainerAccess) -> Self {
Self::from_bits(access.detect.clone())
}
}
#[derive(Component, Reflect)]
#[reflect(Component)]
#[expect(
clippy::struct_excessive_bools,
reason = "each bool is an independent lifecycle/cadence flag with its \
own doc comment, not a state machine in disguise (`dormant`, \
`needs_eval`, and `evaluated_once` are orthogonal cadence \
signals; `waiting_for` (issue #1081) is Latch-only edge state) \
— see the ChoiceFlags precedent in brink-format::opcode"
)]
pub struct FlowSleep<M: Send + Sync + 'static = ()> {
condition: String,
#[reflect(ignore)]
condition_value: Option<Value>,
#[reflect(ignore)]
args: Vec<Value>,
detect: DetectSummary,
arming: WakeArming,
state: SleepState,
dormant: bool,
needs_eval: bool,
evaluated_once: bool,
waiting_for: bool,
reads_bookkeeping: bool,
#[reflect(ignore)]
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> FlowSleep<M> {
#[must_use]
pub fn persistent(condition: impl Into<String>) -> Self {
Self::new(condition.into(), WakeArming::Persistent)
}
#[must_use]
pub fn once(condition: impl Into<String>) -> Self {
Self::new(condition.into(), WakeArming::Once)
}
#[must_use]
pub fn latch(condition: impl Into<String>) -> Self {
Self::new(condition.into(), WakeArming::Latch)
}
fn new(condition: String, arming: WakeArming) -> Self {
Self {
condition,
condition_value: None,
args: Vec::new(),
detect: DetectSummary::default(),
arming,
state: SleepState::Woken,
dormant: false,
needs_eval: false,
evaluated_once: false,
waiting_for: true,
reads_bookkeeping: false,
_marker: PhantomData,
}
}
#[must_use]
pub fn with_args(mut self, args: Vec<Value>) -> Self {
self.args = args;
self
}
#[must_use]
pub fn with_condition_value(mut self, value: Value) -> Self {
self.condition_value = Some(value);
self
}
#[must_use]
pub fn with_detect(mut self, detect: DetectSummary) -> Self {
self.detect = detect;
self
}
#[must_use]
pub fn reads_bookkeeping(mut self) -> Self {
self.reads_bookkeeping = true;
self
}
#[must_use]
pub fn dormant(mut self) -> Self {
self.dormant = true;
self.state = SleepState::Parked;
self
}
pub fn cancel(&mut self) {
self.state = SleepState::Cancelled;
self.needs_eval = false;
}
#[must_use]
pub fn condition(&self) -> &str {
&self.condition
}
#[must_use]
pub fn condition_value(&self) -> Option<&Value> {
self.condition_value.as_ref()
}
#[must_use]
pub fn state(&self) -> SleepState {
self.state
}
#[must_use]
pub fn arming(&self) -> WakeArming {
self.arming
}
#[must_use]
pub fn latch_waiting_for(&self) -> bool {
self.waiting_for
}
#[must_use]
pub fn dependencies_all_detect_capable(&self) -> bool {
self.detect.all_detect_capable
}
#[must_use]
pub fn detect_summary(&self) -> &DetectSummary {
&self.detect
}
#[must_use]
pub fn declares_bookkeeping_reads(&self) -> bool {
self.reads_bookkeeping
}
#[must_use]
pub fn wants_collect(&self) -> bool {
matches!(self.state, SleepState::Woken)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum WakeConditionPurityError {
#[error(
"wake condition `{condition}` does not resolve to a known definition in this story \
(check the name/path is correct for the loaded story)"
)]
UnknownCondition {
condition: String,
},
#[error(
"wake condition value is not a function value (FnRef/Closure) — no target definition to \
check for purity"
)]
NotAFunctionValue,
#[error(
"wake condition `{condition}` resolved to a definition with no EffectRows entry — an \
internal invariant expects one once a story's EffectRows table is populated at all; \
treating conservatively as impure"
)]
MissingEffectRow {
condition: String,
},
#[error(
"wake condition `{condition}` is not pure: it writes global(s) {writes:?} — a FlowSleep \
condition is re-evaluated whenever a dependency moves (docs/effects-spec.md §13.1 point \
2), and a writing condition would let that re-evaluation observe or cause a mutation"
)]
Writes {
condition: String,
writes: Vec<String>,
},
#[error(
"wake condition `{condition}`'s effect row is opaque (a call it makes couldn't be \
summarized by effects inference) — purity can't be proven, so it is conservatively \
rejected"
)]
Opaque {
condition: String,
},
#[error(
"wake condition `{condition}` is not pure: it calls EXTERNAL `{external}`, whose \
capability manifest declares writes {writes:?} — a FlowSleep condition is \
re-evaluated whenever a dependency moves (docs/effects-spec.md §13.1 point 2), and a \
writing binding would let that re-evaluation observe or cause a mutation \
(docs/effects-spec.md §9/§13, issue #1040)"
)]
ExternalWrites {
condition: String,
external: String,
writes: Vec<String>,
},
#[error(
"wake condition `{condition}` is not pure: it calls `{external}`, a bind_brink_command \
binding — a command binding mutates the World when triggered, and a FlowSleep \
condition is re-evaluated whenever a dependency moves (docs/effects-spec.md §13.1 \
point 2), so a command-bound wake condition is rejected regardless of \
CapabilityManifest presence (issue #1609)"
)]
CommandBinding {
condition: String,
external: String,
},
}
fn effect_row_for(effect_rows: &[EffectRowEntry], def: DefinitionId) -> Option<&EffectRowEntry> {
effect_rows.iter().find(|row| row.def == def)
}
fn write_names(program: &Program, ids: &[DefinitionId]) -> Vec<String> {
ids.iter()
.map(|id| {
program
.global_var_name(*id)
.map_or_else(|| format!("<{id}>"), str::to_owned)
})
.collect()
}
fn check_row_purity<M: Send + Sync + 'static>(
program: &Program,
row: &EffectRowEntry,
manifest: &CapabilityManifest,
bindings: Option<&BrinkBindings<M>>,
condition_label: &str,
) -> Result<(), WakeConditionPurityError> {
if row.direct.opaque
|| row
.dispatches
.iter()
.any(|dispatch| dispatch.fallback.opaque)
{
return Err(WakeConditionPurityError::Opaque {
condition: condition_label.to_owned(),
});
}
check_external_calls_purity(program, &row.direct, manifest, bindings, condition_label)?;
for dispatch in &row.dispatches {
check_external_calls_purity(
program,
&dispatch.fallback,
manifest,
bindings,
condition_label,
)?;
}
let mut writes = write_names(program, &row.direct.writes);
for dispatch in &row.dispatches {
writes.extend(write_names(program, &dispatch.fallback.writes));
}
if writes.is_empty() {
Ok(())
} else {
writes.sort();
writes.dedup();
Err(WakeConditionPurityError::Writes {
condition: condition_label.to_owned(),
writes,
})
}
}
fn check_external_calls_purity<M: Send + Sync + 'static>(
program: &Program,
direct: &DirectEffects,
manifest: &CapabilityManifest,
bindings: Option<&BrinkBindings<M>>,
condition_label: &str,
) -> Result<(), WakeConditionPurityError> {
for call in &direct.calls {
let external_name = program
.name_checked(call.name)
.map_or_else(|| format!("<{:?}>", call.name), str::to_owned);
if bindings.is_some_and(|b| b.is_command(&external_name)) {
return Err(WakeConditionPurityError::CommandBinding {
condition: condition_label.to_owned(),
external: external_name,
});
}
if let Some(external) = manifest.external(&external_name)
&& !external.effects.writes.is_empty()
{
let mut writes = external.effects.writes.clone();
writes.sort();
writes.dedup();
return Err(WakeConditionPurityError::ExternalWrites {
condition: condition_label.to_owned(),
external: external_name,
writes,
});
}
}
Ok(())
}
pub fn check_named_condition_purity<M: Send + Sync + 'static>(
program: &Program,
effect_rows: &[EffectRowEntry],
manifest: &CapabilityManifest,
bindings: Option<&BrinkBindings<M>>,
condition: &str,
) -> Result<(), WakeConditionPurityError> {
if effect_rows.is_empty() {
return Ok(());
}
let def = program.definition_id_for_path(condition).ok_or_else(|| {
WakeConditionPurityError::UnknownCondition {
condition: condition.to_owned(),
}
})?;
let row = effect_row_for(effect_rows, def).ok_or_else(|| {
WakeConditionPurityError::MissingEffectRow {
condition: condition.to_owned(),
}
})?;
check_row_purity(program, row, manifest, bindings, condition)
}
pub fn check_value_condition_purity<M: Send + Sync + 'static>(
program: &Program,
effect_rows: &[EffectRowEntry],
manifest: &CapabilityManifest,
bindings: Option<&BrinkBindings<M>>,
value: &Value,
) -> Result<(), WakeConditionPurityError> {
if effect_rows.is_empty() {
return Ok(());
}
let def = value
.fn_target()
.ok_or(WakeConditionPurityError::NotAFunctionValue)?;
let label = program
.divert_target_path(def)
.unwrap_or_else(|| format!("<{def}>"));
let row = effect_row_for(effect_rows, def).ok_or_else(|| {
WakeConditionPurityError::MissingEffectRow {
condition: label.clone(),
}
})?;
check_row_purity(program, row, manifest, bindings, &label)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ConditionReads {
Unknown,
Globals(BTreeSet<u32>),
}
fn condition_reads<M: Send + Sync + 'static>(
asset: Option<&ProgramAsset>,
sleep: &FlowSleep<M>,
) -> ConditionReads {
let Some(asset) = asset else {
return ConditionReads::Unknown;
};
if asset.effect_rows.is_empty() {
return ConditionReads::Unknown;
}
let def = if let Some(value) = &sleep.condition_value {
value.fn_target()
} else {
asset.program.definition_id_for_path(&sleep.condition)
};
let Some(def) = def else {
return ConditionReads::Unknown;
};
let Some(row) = effect_row_for(&asset.effect_rows, def) else {
return ConditionReads::Unknown;
};
if row.direct.opaque
|| row
.dispatches
.iter()
.any(|dispatch| dispatch.fallback.opaque)
{
return ConditionReads::Unknown;
}
let mut slots = BTreeSet::new();
let reads = row
.direct
.reads
.iter()
.chain(row.dispatches.iter().flat_map(|d| d.fallback.reads.iter()));
for id in reads {
let Some(slot) = asset.program.global_slot(*id) else {
return ConditionReads::Unknown;
};
slots.insert(slot);
}
ConditionReads::Globals(slots)
}
fn delta_touches_condition<M: Send + Sync + 'static>(
delta: &WorldDelta,
reads: &ConditionReads,
sleep: &FlowSleep<M>,
) -> bool {
match reads {
ConditionReads::Unknown => !delta.is_empty(),
ConditionReads::Globals(slots) => {
(delta.touched_bookkeeping() && sleep.reads_bookkeeping)
|| delta.globals().iter().any(|slot| slots.contains(slot))
}
}
}
fn is_condition_true(value: &Value) -> bool {
match value {
Value::Bool(b) => *b,
Value::Int(n) => *n != 0,
Value::Float(f) => *f != 0.0,
_ => false,
}
}
fn wake_needs_reeval<M: Send + Sync + 'static>(
sleep: &FlowSleep<M>,
registry: &CapabilityRegistry<M>,
changes: &CapabilityChanges<M>,
world_changed: bool,
) -> bool {
if !sleep.evaluated_once {
return true;
}
let detect = &sleep.detect;
if detect.bits.is_empty() {
return world_changed;
}
if !detect.all_detect_capable {
return true;
}
if world_changed {
return true;
}
detect.bits.keys().any(|name| {
registry
.type_id(name)
.and_then(|ty| changes.changed(ty))
.unwrap_or(true)
})
}
#[expect(
clippy::needless_pass_by_value,
reason = "bevy systems take Res/Query by value"
)]
pub fn mark_wake_dirty<M: Send + Sync + 'static>(
globals: Option<Res<BrinkGlobals<M>>>,
registry: Res<CapabilityRegistry<M>>,
changes: Res<CapabilityChanges<M>>,
wake_delta: Option<ResMut<BrinkWorldDelta<M>>>,
programs: Option<Res<Assets<ProgramAsset>>>,
mut sleepers: Query<(&mut FlowSleep<M>, Option<&BrinkProgram<M>>)>,
) {
let coarse_changed = globals.as_ref().is_some_and(DetectChanges::is_changed);
let globals_tick = globals.as_ref().map(DetectChanges::last_changed);
let delta = wake_delta
.map(ResMut::into_inner)
.and_then(|ledger| ledger.drain(globals_tick, coarse_changed));
for (mut sleep, program_ref) in &mut sleepers {
if sleep.state != SleepState::Parked {
continue;
}
let world_changed = match &delta {
None => coarse_changed,
Some(delta) => {
!delta.is_empty() && {
let asset = program_ref
.zip(programs.as_ref())
.and_then(|(program_ref, assets)| assets.get(&program_ref.handle));
let reads = condition_reads(asset, &sleep);
delta_touches_condition(delta, &reads, &sleep)
}
}
};
if wake_needs_reeval(&sleep, ®istry, &changes, world_changed) && !sleep.needs_eval {
sleep.needs_eval = true;
}
}
}
struct WakeCandidate {
entity: Entity,
condition: String,
condition_value: Option<Value>,
args: Vec<Value>,
}
enum ReparkAction {
Rearm,
Remove,
}
type SleepGatherQuery<M> = QueryState<(
Entity,
&'static FlowSleep<M>,
&'static BrinkFlow<M>,
&'static BrinkProgram<M>,
)>;
#[expect(
clippy::too_many_lines,
reason = "four coherent phases (gather, purity faults, re-park/retire, evaluate) that share \
locals across the whole pass; splitting would just move the length into extra \
parameter-passing"
)]
pub fn run_flow_sleep<M: Send + Sync + 'static>(
world: &mut EcsWorld,
mut gather: Local<SleepGatherQuery<M>>,
) {
let mut candidates: Vec<WakeCandidate> = Vec::new();
let mut reparks: Vec<(Entity, ReparkAction)> = Vec::new();
let mut purity_faults: Vec<(Entity, WakeConditionPurityError)> = Vec::new();
{
let programs = world.resource::<Assets<ProgramAsset>>();
let manifest = world.resource::<CapabilityManifest>();
let bindings = world.get_resource::<BrinkBindings<M>>();
for (entity, sleep, flow, program_ref) in gather.iter(world) {
let status = flow.inner.status();
if status == StoryStatus::Ended {
reparks.push((entity, ReparkAction::Remove));
continue;
}
match sleep.state {
SleepState::Cancelled | SleepState::Faulted => {}
SleepState::Woken => {
if status == StoryStatus::Done {
reparks.push((
entity,
match sleep.arming {
WakeArming::Persistent | WakeArming::Latch => ReparkAction::Rearm,
WakeArming::Once => ReparkAction::Remove,
},
));
}
}
SleepState::Parked => {
let eligible = sleep.dormant || status == StoryStatus::Done;
if eligible && sleep.needs_eval {
if let Some(asset) = programs.get(&program_ref.handle) {
let purity = if let Some(value) = &sleep.condition_value {
check_value_condition_purity(
&asset.program,
&asset.effect_rows,
manifest,
bindings,
value,
)
} else {
check_named_condition_purity(
&asset.program,
&asset.effect_rows,
manifest,
bindings,
&sleep.condition,
)
};
match purity {
Ok(()) => candidates.push(WakeCandidate {
entity,
condition: sleep.condition.clone(),
condition_value: sleep.condition_value.clone(),
args: sleep.args.clone(),
}),
Err(err) => purity_faults.push((entity, err)),
}
}
}
}
}
}
}
for (entity, err) in purity_faults {
if let Some(mut sleep) = world.get_mut::<FlowSleep<M>>(entity)
&& sleep.state == SleepState::Parked
{
warn!(
"brink wake condition `{}` rejected for flow {:?}: {err} — policy parked \
(Faulted); a FlowSleep condition must be pure (docs/effects-spec.md §13.1 \
point 2)",
sleep.condition, entity
);
sleep.state = SleepState::Faulted;
sleep.needs_eval = false;
}
}
for (entity, action) in reparks {
match action {
ReparkAction::Rearm => {
if let Some(mut sleep) = world.get_mut::<FlowSleep<M>>(entity) {
sleep.state = SleepState::Parked;
sleep.dormant = false;
sleep.needs_eval = false;
}
}
ReparkAction::Remove => {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.remove::<FlowSleep<M>>();
}
}
}
}
if candidates.is_empty() {
return;
}
let globals_tick = world
.get_resource_ref::<BrinkGlobals<M>>()
.map(|globals| globals.last_changed());
for candidate in candidates {
let outcome = if let Some(value) = &candidate.condition_value {
call_ink_function_value::<M>(world, candidate.entity, value, &candidate.args)
} else {
call_ink_function::<M>(
world,
candidate.entity,
&candidate.condition,
&candidate.args,
)
};
let Some(mut sleep) = world.get_mut::<FlowSleep<M>>(candidate.entity) else {
continue;
};
if sleep.state != SleepState::Parked {
continue;
}
sleep.needs_eval = false;
sleep.evaluated_once = true;
match outcome {
Ok(value) => {
let raw = is_condition_true(&value);
let fires = match sleep.arming {
WakeArming::Persistent | WakeArming::Once => raw,
WakeArming::Latch => raw == sleep.waiting_for,
};
if fires {
sleep.state = SleepState::Woken;
if sleep.arming == WakeArming::Latch {
sleep.waiting_for = !sleep.waiting_for;
}
}
}
Err(err) => {
warn!(
"brink wake condition `{}` faulted for flow {:?}: {err} — \
policy parked (Faulted); host must clear or replace it",
candidate.condition, candidate.entity
);
sleep.state = SleepState::Faulted;
}
}
}
if let Some(tick) = globals_tick
&& let Some(mut globals) = world.get_resource_mut::<BrinkGlobals<M>>()
{
globals.set_last_changed(tick);
}
if let Some(mut ledger) = world.get_resource_mut::<BrinkWorldDelta<M>>() {
ledger.record_condition_evaluation();
}
}
#[cfg(test)]
mod tests;