Skip to main content

moirai/schedule/
condition.rs

1//! Run conditions gating systems and system sets against read-only world state.
2//!
3//! Tick-based predicates advance cursors in [`crate::schedule::RunContext`] after a
4//! system runs; set gates cache one evaluation per stage pass.
5
6use alloc::boxed::Box;
7use alloc::rc::Rc;
8use core::any::TypeId;
9
10use crate::schedule::RunContext;
11use crate::state::State;
12use crate::time::ChangeTick;
13use crate::world::World;
14
15type Predicate = Rc<dyn Fn(&World) -> bool>;
16
17#[derive(Clone, Copy)]
18struct StateProbe {
19    type_id: TypeId,
20    transition_tick: fn(&World) -> Option<ChangeTick>,
21    pending: fn(&World) -> bool,
22}
23
24impl StateProbe {
25    fn of<S: Eq + 'static>() -> Self {
26        Self {
27            type_id: TypeId::of::<State<S>>(),
28            transition_tick: |world| {
29                world
30                    .resource::<State<S>>()
31                    .ok()
32                    .flatten()
33                    .and_then(State::transition_tick)
34            },
35            pending: |world| {
36                world
37                    .resource::<State<S>>()
38                    .ok()
39                    .flatten()
40                    .is_some_and(|state| state.pending().is_some())
41            },
42        }
43    }
44}
45
46/// Run condition evaluated against read-only world state before a system body runs.
47#[derive(Clone)]
48pub struct Condition(ConditionKind);
49
50/// Invalid fixed-step cadence configuration.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum ConditionError {
53    /// Fixed-step cadence period must be nonzero.
54    ZeroPeriod,
55    /// Period must be a power of two for bitmask indexing.
56    PeriodNotPowerOfTwo { period: u64 },
57    /// Phase must be strictly less than the period.
58    PhaseOutOfRange { period: u64, phase: u64 },
59}
60
61#[derive(Clone)]
62enum ConditionKind {
63    Always,
64    Never,
65    ResourceExists(TypeId),
66    ResourceAdded(TypeId),
67    ResourceChanged(TypeId),
68    StateChanged(StateProbe),
69    StatePending(StateProbe),
70    FixedStepMod { mask: u64, phase: u64 },
71    And(Box<ConditionKind>, Box<ConditionKind>),
72    Or(Box<ConditionKind>, Box<ConditionKind>),
73    Predicate(Predicate),
74}
75
76impl Condition {
77    /// Unconditional pass; default gate for newly registered system sets.
78    pub const fn always() -> Self {
79        Self(ConditionKind::Always)
80    }
81
82    /// Never runs the gated system or set.
83    pub const fn never() -> Self {
84        Self(ConditionKind::Never)
85    }
86
87    /// Passes while the resource type is present in the world.
88    pub fn resource_exists<R: 'static>() -> Self {
89        Self(ConditionKind::ResourceExists(TypeId::of::<R>()))
90    }
91
92    /// Passes once per resource insertion until the system cursor advances.
93    pub fn resource_added<R: 'static>() -> Self {
94        Self(ConditionKind::ResourceAdded(TypeId::of::<R>()))
95    }
96
97    /// Passes once per resource mutation until the system cursor advances.
98    pub fn resource_changed<R: 'static>() -> Self {
99        Self(ConditionKind::ResourceChanged(TypeId::of::<R>()))
100    }
101
102    /// Passes once per applied state transition until the cursor advances.
103    pub fn state_changed<S: Eq + 'static>() -> Self {
104        Self(ConditionKind::StateChanged(StateProbe::of::<S>()))
105    }
106
107    /// Runs while a state transition has been requested but not yet applied.
108    pub fn state_pending<S: Eq + 'static>() -> Self {
109        Self(ConditionKind::StatePending(StateProbe::of::<S>()))
110    }
111
112    /// Runs on one phase of a power-of-two fixed-step cadence.
113    ///
114    /// The condition is false outside `FixedUpdate`. Fixed-step indices are
115    /// zero-based, so `(8, 0)` includes the first fixed substep.
116    pub fn fixed_step_mod(period: u64, phase: u64) -> Result<Self, ConditionError> {
117        if period == 0 {
118            return Err(ConditionError::ZeroPeriod);
119        }
120        if !period.is_power_of_two() {
121            return Err(ConditionError::PeriodNotPowerOfTwo { period });
122        }
123        if phase >= period {
124            return Err(ConditionError::PhaseOutOfRange { period, phase });
125        }
126        Ok(Self(ConditionKind::FixedStepMod {
127            mask: period - 1,
128            phase,
129        }))
130    }
131
132    /// Creates a cloneable condition from a read-only world predicate.
133    pub fn from_world<F>(predicate: F) -> Self
134    where
135        F: Fn(&World) -> bool + 'static,
136    {
137        Self::predicate(Rc::new(predicate))
138    }
139
140    /// Passes while [`crate::state::State`] holds the given value.
141    pub fn in_state<S: Eq + 'static>(value: S) -> Self {
142        let expected = value;
143        Self::from_world(move |world| {
144            world
145                .state_current::<S>()
146                .ok()
147                .flatten()
148                .is_some_and(|current| *current == expected)
149        })
150    }
151
152    /// Conjunction; both sides must pass.
153    pub fn and(self, other: Self) -> Self {
154        Self(ConditionKind::And(Box::new(self.0), Box::new(other.0)))
155    }
156
157    /// Disjunction; either side may pass.
158    pub fn or(self, other: Self) -> Self {
159        Self(ConditionKind::Or(Box::new(self.0), Box::new(other.0)))
160    }
161
162    fn predicate(predicate: Predicate) -> Self {
163        Self(ConditionKind::Predicate(predicate))
164    }
165
166    pub(crate) fn evaluate(
167        &self,
168        world: &World,
169        system_index: usize,
170        context: &RunContext,
171    ) -> bool {
172        evaluate_kind(&self.0, world, system_index, context)
173    }
174
175    pub(crate) fn evaluate_for_set(
176        &self,
177        world: &World,
178        set_index: usize,
179        context: &RunContext,
180    ) -> bool {
181        evaluate_kind_for_set(&self.0, world, set_index, context)
182    }
183
184    pub(crate) fn advance_cursors(
185        &self,
186        world: &World,
187        system_index: usize,
188        context: &mut RunContext,
189    ) {
190        advance_kind_cursors(&self.0, world, system_index, context);
191    }
192
193    pub(crate) fn advance_set_cursors(
194        &self,
195        world: &World,
196        set_index: usize,
197        context: &mut RunContext,
198    ) {
199        advance_kind_set_cursors(&self.0, world, set_index, context);
200    }
201}
202
203fn evaluate_kind(
204    kind: &ConditionKind,
205    world: &World,
206    system_index: usize,
207    context: &RunContext,
208) -> bool {
209    match kind {
210        ConditionKind::Always => true,
211        ConditionKind::Never => false,
212        ConditionKind::ResourceExists(type_id) => world.resource_present(*type_id),
213        ConditionKind::ResourceAdded(type_id) => resource_tick_advanced(
214            world.resource_added_tick_for(*type_id),
215            context.resource_added_cursor(system_index, *type_id),
216        ),
217        ConditionKind::ResourceChanged(type_id) => resource_tick_advanced(
218            world.resource_changed_tick_for(*type_id),
219            context.resource_changed_cursor(system_index, *type_id),
220        ),
221        ConditionKind::StateChanged(probe) => state_tick_advanced(
222            (probe.transition_tick)(world),
223            context.state_transition_cursor(system_index, probe.type_id),
224        ),
225        ConditionKind::StatePending(probe) => (probe.pending)(world),
226        ConditionKind::FixedStepMod { mask, phase } => context
227            .fixed_step
228            .is_some_and(|step| step.index & mask == *phase),
229        ConditionKind::And(left, right) => {
230            evaluate_kind(left, world, system_index, context)
231                && evaluate_kind(right, world, system_index, context)
232        }
233        ConditionKind::Or(left, right) => {
234            evaluate_kind(left, world, system_index, context)
235                || evaluate_kind(right, world, system_index, context)
236        }
237        ConditionKind::Predicate(predicate) => predicate(world),
238    }
239}
240
241fn evaluate_kind_for_set(
242    kind: &ConditionKind,
243    world: &World,
244    set_index: usize,
245    context: &RunContext,
246) -> bool {
247    match kind {
248        ConditionKind::Always => true,
249        ConditionKind::Never => false,
250        ConditionKind::ResourceExists(type_id) => world.resource_present(*type_id),
251        ConditionKind::ResourceAdded(type_id) => resource_tick_advanced(
252            world.resource_added_tick_for(*type_id),
253            context.resource_added_cursor_for_set(set_index, *type_id),
254        ),
255        ConditionKind::ResourceChanged(type_id) => resource_tick_advanced(
256            world.resource_changed_tick_for(*type_id),
257            context.resource_changed_cursor_for_set(set_index, *type_id),
258        ),
259        ConditionKind::StateChanged(probe) => state_tick_advanced(
260            (probe.transition_tick)(world),
261            context.state_transition_cursor_for_set(set_index, probe.type_id),
262        ),
263        ConditionKind::StatePending(probe) => (probe.pending)(world),
264        ConditionKind::FixedStepMod { mask, phase } => context
265            .fixed_step
266            .is_some_and(|step| step.index & mask == *phase),
267        ConditionKind::And(left, right) => {
268            evaluate_kind_for_set(left, world, set_index, context)
269                && evaluate_kind_for_set(right, world, set_index, context)
270        }
271        ConditionKind::Or(left, right) => {
272            evaluate_kind_for_set(left, world, set_index, context)
273                || evaluate_kind_for_set(right, world, set_index, context)
274        }
275        ConditionKind::Predicate(predicate) => predicate(world),
276    }
277}
278
279fn advance_kind_cursors(
280    kind: &ConditionKind,
281    world: &World,
282    system_index: usize,
283    context: &mut RunContext,
284) {
285    match kind {
286        ConditionKind::ResourceAdded(type_id) => {
287            if let Some(tick) = world.resource_added_tick_for(*type_id) {
288                context.set_resource_added_cursor(system_index, *type_id, tick);
289            }
290        }
291        ConditionKind::ResourceChanged(type_id) => {
292            if let Some(tick) = world.resource_changed_tick_for(*type_id) {
293                context.set_resource_changed_cursor(system_index, *type_id, tick);
294            }
295        }
296        ConditionKind::StateChanged(probe) => {
297            if let Some(tick) = (probe.transition_tick)(world) {
298                context.set_state_transition_cursor(system_index, probe.type_id, tick);
299            }
300        }
301        ConditionKind::And(left, right) | ConditionKind::Or(left, right) => {
302            advance_kind_cursors(left, world, system_index, context);
303            advance_kind_cursors(right, world, system_index, context);
304        }
305        _ => {}
306    }
307}
308
309fn advance_kind_set_cursors(
310    kind: &ConditionKind,
311    world: &World,
312    set_index: usize,
313    context: &mut RunContext,
314) {
315    match kind {
316        ConditionKind::ResourceAdded(type_id) => {
317            if let Some(tick) = world.resource_added_tick_for(*type_id) {
318                context.set_resource_added_cursor_for_set(set_index, *type_id, tick);
319            }
320        }
321        ConditionKind::ResourceChanged(type_id) => {
322            if let Some(tick) = world.resource_changed_tick_for(*type_id) {
323                context.set_resource_changed_cursor_for_set(set_index, *type_id, tick);
324            }
325        }
326        ConditionKind::StateChanged(probe) => {
327            if let Some(tick) = (probe.transition_tick)(world) {
328                context.set_state_transition_cursor_for_set(set_index, probe.type_id, tick);
329            }
330        }
331        ConditionKind::And(left, right) | ConditionKind::Or(left, right) => {
332            advance_kind_set_cursors(left, world, set_index, context);
333            advance_kind_set_cursors(right, world, set_index, context);
334        }
335        _ => {}
336    }
337}
338
339impl core::fmt::Debug for Condition {
340    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
341        f.write_str("Condition")
342    }
343}
344
345impl core::fmt::Display for ConditionError {
346    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
347        match self {
348            Self::ZeroPeriod => f.write_str("fixed-step cadence period must be nonzero"),
349            Self::PeriodNotPowerOfTwo { period } => {
350                write!(
351                    f,
352                    "fixed-step cadence period {period} is not a power of two"
353                )
354            }
355            Self::PhaseOutOfRange { period, phase } => write!(
356                f,
357                "fixed-step cadence phase {phase} is outside period {period}"
358            ),
359        }
360    }
361}
362
363#[cfg(feature = "std")]
364impl std::error::Error for ConditionError {}
365
366fn resource_tick_advanced(current: Option<ChangeTick>, cursor: ChangeTick) -> bool {
367    current.is_some_and(|tick| tick > cursor)
368}
369
370fn state_tick_advanced(current: Option<ChangeTick>, cursor: ChangeTick) -> bool {
371    current.is_some_and(|tick| tick > cursor)
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::schedule::RunContext;
378    use crate::time::ChangeTick;
379    use crate::world::WorldBuilder;
380    use alloc::string::ToString;
381
382    #[derive(Clone)]
383    struct Score(#[allow(dead_code)] i32);
384
385    #[test]
386    fn fixed_step_mod_validates_binary_cadence() {
387        assert_eq!(
388            Condition::fixed_step_mod(0, 0).err(),
389            Some(ConditionError::ZeroPeriod)
390        );
391        assert_eq!(
392            Condition::fixed_step_mod(3, 0).err(),
393            Some(ConditionError::PeriodNotPowerOfTwo { period: 3 })
394        );
395        assert_eq!(
396            Condition::fixed_step_mod(4, 4).err(),
397            Some(ConditionError::PhaseOutOfRange {
398                period: 4,
399                phase: 4,
400            })
401        );
402        assert_eq!(
403            ConditionError::ZeroPeriod.to_string(),
404            "fixed-step cadence period must be nonzero"
405        );
406        assert!(ConditionError::PeriodNotPowerOfTwo { period: 3 }
407            .to_string()
408            .contains('3'));
409        assert!(ConditionError::PhaseOutOfRange {
410            period: 4,
411            phase: 4
412        }
413        .to_string()
414        .contains('4'));
415    }
416
417    #[test]
418    fn fixed_step_mod_uses_zero_based_mask_and_is_false_outside_fixed_update() {
419        let world = WorldBuilder::new().build().expect("world");
420        let condition = Condition::fixed_step_mod(4, 0).expect("condition");
421        let mut context = RunContext::new();
422        assert!(!condition.evaluate(&world, 0, &context));
423
424        for index in 0..8 {
425            context.fixed_step = Some(crate::time::FixedStep {
426                index,
427                delta: core::time::Duration::from_millis(16),
428                steps: 1,
429            });
430            assert_eq!(condition.evaluate(&world, 0, &context), index % 4 == 0);
431            assert_eq!(
432                condition.evaluate_for_set(&world, 0, &context),
433                index % 4 == 0
434            );
435        }
436    }
437
438    #[test]
439    fn resource_added_and_changed_advance_system_cursors() {
440        let mut builder = WorldBuilder::new();
441        builder.register_resource::<Score>();
442        let mut world = builder.build().expect("build");
443        let mut context = RunContext::new();
444
445        assert!(!Condition::resource_added::<Score>().evaluate(&world, 0, &context));
446        world.insert_resource(Score(1)).expect("insert");
447        assert!(Condition::resource_added::<Score>().evaluate(&world, 0, &context));
448        Condition::resource_added::<Score>().advance_cursors(&world, 0, &mut context);
449        assert!(!Condition::resource_added::<Score>().evaluate(&world, 0, &context));
450
451        world.insert_resource(Score(2)).expect("replace");
452        assert!(Condition::resource_changed::<Score>().evaluate(&world, 1, &context));
453        Condition::resource_changed::<Score>().advance_cursors(&world, 1, &mut context);
454        assert!(!Condition::resource_changed::<Score>().evaluate(&world, 1, &context));
455    }
456
457    #[test]
458    fn resource_added_evaluate_for_set_advances_set_cursors() {
459        let mut builder = WorldBuilder::new();
460        builder.register_resource::<Score>();
461        let mut world = builder.build().expect("build");
462        let mut context = RunContext::with_set_capacity(1);
463
464        world.insert_resource(Score(1)).expect("insert");
465        let condition = Condition::resource_added::<Score>();
466        assert!(condition.evaluate_for_set(&world, 0, &context));
467        condition.advance_set_cursors(&world, 0, &mut context);
468        assert!(!condition.evaluate_for_set(&world, 0, &context));
469    }
470
471    #[test]
472    fn and_or_combinators_delegate_cursor_advance() {
473        let mut builder = WorldBuilder::new();
474        builder.register_resource::<Score>();
475        let mut world = builder.build().expect("build");
476        let mut context = RunContext::new();
477        world.insert_resource(Score(1)).expect("insert");
478
479        let condition =
480            Condition::resource_added::<Score>().and(Condition::resource_changed::<Score>());
481        assert!(condition.evaluate(&world, 0, &context));
482        condition.advance_cursors(&world, 0, &mut context);
483
484        world.insert_resource(Score(2)).expect("change");
485        let or_condition = Condition::never().or(Condition::resource_changed::<Score>());
486        assert!(or_condition.evaluate(&world, 1, &context));
487        or_condition.advance_set_cursors(&world, 1, &mut context);
488        let tick = world
489            .resource_changed_tick_for(core::any::TypeId::of::<Score>())
490            .expect("tick");
491        assert_eq!(
492            context.resource_changed_cursor_for_set(1, core::any::TypeId::of::<Score>()),
493            tick
494        );
495    }
496
497    #[test]
498    fn state_changed_condition_tracks_transitions() {
499        use crate::state::State;
500
501        let mut builder = WorldBuilder::new();
502        builder.register_state::<u8>();
503        let mut world = builder.build().expect("build");
504        let mut context = RunContext::new();
505
506        world.insert_resource(State::new(1u8)).expect("state");
507        world
508            .resource_mut::<State<u8>>()
509            .expect("mut")
510            .expect("present")
511            .request(2)
512            .expect("request");
513        let tick = world.issue_change_tick_for_state().expect("tick");
514        world
515            .resource_mut::<State<u8>>()
516            .expect("mut")
517            .expect("present")
518            .apply_pending(tick);
519
520        let condition = Condition::state_changed::<u8>();
521        assert!(condition.evaluate(&world, 0, &context));
522        condition.advance_cursors(&world, 0, &mut context);
523        assert!(!condition.evaluate(&world, 0, &context));
524    }
525
526    #[test]
527    fn resource_tick_advanced_helper() {
528        let tick = ChangeTick::from_raw(5);
529        assert!(!resource_tick_advanced(Some(ChangeTick::from_raw(4)), tick));
530        assert!(resource_tick_advanced(Some(ChangeTick::from_raw(6)), tick));
531        assert!(!resource_tick_advanced(None, tick));
532        assert!(!state_tick_advanced(Some(ChangeTick::from_raw(4)), tick));
533        assert!(state_tick_advanced(Some(ChangeTick::from_raw(6)), tick));
534    }
535
536    #[test]
537    fn evaluate_for_set_covers_exists_changed_state_and_combinators() {
538        use crate::state::State;
539
540        let mut builder = WorldBuilder::new();
541        builder.register_resource::<Score>();
542        builder.register_state::<u8>();
543        let mut world = builder.build().expect("build");
544        let mut context = RunContext::with_set_capacity(2);
545
546        assert!(!Condition::resource_exists::<Score>().evaluate_for_set(&world, 0, &context));
547        assert!(!Condition::never().evaluate_for_set(&world, 0, &context));
548        assert!(Condition::always().evaluate_for_set(&world, 0, &context));
549
550        world.insert_resource(Score(1)).expect("insert");
551        assert!(Condition::resource_exists::<Score>().evaluate_for_set(&world, 0, &context));
552
553        world.insert_resource(Score(2)).expect("change");
554        let changed = Condition::resource_changed::<Score>();
555        assert!(changed.evaluate_for_set(&world, 0, &context));
556        changed.advance_set_cursors(&world, 0, &mut context);
557        assert!(!changed.evaluate_for_set(&world, 0, &context));
558
559        world.insert_resource(State::new(1u8)).expect("state");
560        world
561            .resource_mut::<State<u8>>()
562            .expect("mut")
563            .expect("present")
564            .request(2)
565            .expect("request");
566        let tick = world.issue_change_tick_for_state().expect("tick");
567        world
568            .resource_mut::<State<u8>>()
569            .expect("mut")
570            .expect("present")
571            .apply_pending(tick);
572        let state_changed = Condition::state_changed::<u8>();
573        assert!(state_changed.evaluate_for_set(&world, 1, &context));
574        state_changed.advance_set_cursors(&world, 1, &mut context);
575        assert!(!state_changed.evaluate_for_set(&world, 1, &context));
576
577        let and = Condition::always().and(Condition::resource_exists::<Score>());
578        assert!(and.evaluate_for_set(&world, 0, &context));
579        let or = Condition::never().or(Condition::resource_exists::<Score>());
580        assert!(or.evaluate_for_set(&world, 0, &context));
581
582        let predicate = Condition::in_state(2u8);
583        assert!(predicate.evaluate_for_set(&world, 0, &context));
584
585        let and = Condition::resource_added::<Score>().and(Condition::resource_changed::<Score>());
586        and.advance_cursors(&world, 0, &mut RunContext::new());
587        and.advance_set_cursors(&world, 0, &mut context);
588    }
589
590    #[test]
591    fn advancing_absent_temporal_values_leaves_cursors_at_zero() {
592        use crate::state::State;
593        use core::any::TypeId;
594
595        let mut builder = WorldBuilder::new();
596        builder.register_resource::<Score>();
597        builder.register_state::<u8>();
598        let world = builder.build().expect("build");
599        let mut context = RunContext::with_set_capacity(1);
600
601        let score = TypeId::of::<Score>();
602        let state = TypeId::of::<State<u8>>();
603        for condition in [
604            Condition::resource_added::<Score>(),
605            Condition::resource_changed::<Score>(),
606            Condition::state_changed::<u8>(),
607        ] {
608            condition.advance_cursors(&world, 0, &mut context);
609            condition.advance_set_cursors(&world, 0, &mut context);
610        }
611
612        assert_eq!(context.resource_added_cursor(0, score), ChangeTick::ZERO);
613        assert_eq!(context.resource_changed_cursor(0, score), ChangeTick::ZERO);
614        assert_eq!(context.state_transition_cursor(0, state), ChangeTick::ZERO);
615        assert_eq!(
616            context.resource_added_cursor_for_set(0, score),
617            ChangeTick::ZERO
618        );
619        assert_eq!(
620            context.resource_changed_cursor_for_set(0, score),
621            ChangeTick::ZERO
622        );
623        assert_eq!(
624            context.state_transition_cursor_for_set(0, state),
625            ChangeTick::ZERO
626        );
627    }
628}