Skip to main content

moirai/
state.rs

1//! Explicit host state machine backed by a [`crate::world::World`] resource.
2//!
3//! [`State`] queues at most one pending transition per frame. [`apply`] commits pending values on a
4//! dedicated schedule boundary; [`on_exit`], [`on_transition`], and [`on_enter`] observe the ordered
5//! transition lifecycle through [`crate::schedule::Condition`] gates.
6
7use crate::time::ChangeTick;
8
9/// Failure to queue a state transition on [`State`].
10#[non_exhaustive]
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub enum StateError {
13    /// A different pending target is already queued.
14    ConflictingTransition,
15}
16
17/// Host-owned state resource with explicit current, previous, and pending boundaries.
18pub struct State<S: Eq + 'static> {
19    current: S,
20    previous: Option<S>,
21    pending: Option<S>,
22    transition_tick: Option<ChangeTick>,
23}
24
25impl<S: Eq + 'static> State<S> {
26    /// Creates state with `initial` as [`Self::current`] and no pending transition.
27    pub fn new(initial: S) -> Self {
28        Self {
29            current: initial,
30            previous: None,
31            pending: None,
32            transition_tick: None,
33        }
34    }
35
36    /// Active state value after the last committed transition.
37    pub fn current(&self) -> &S {
38        &self.current
39    }
40
41    /// Outgoing value from the most recent committed transition, if any.
42    pub fn previous(&self) -> Option<&S> {
43        self.previous.as_ref()
44    }
45
46    /// Requested next value awaiting [`apply`], if any.
47    pub fn pending(&self) -> Option<&S> {
48        self.pending.as_ref()
49    }
50
51    /// [`ChangeTick`] recorded when the last transition committed.
52    pub fn transition_tick(&self) -> Option<ChangeTick> {
53        self.transition_tick
54    }
55
56    /// Queues `next` for commit by [`apply`]; idempotent when already current or pending.
57    pub fn request(&mut self, next: S) -> Result<(), StateError> {
58        if let Some(pending) = &self.pending {
59            if *pending == next {
60                return Ok(());
61            }
62            return Err(StateError::ConflictingTransition);
63        }
64        if self.current == next {
65            return Ok(());
66        }
67        self.pending = Some(next);
68        Ok(())
69    }
70
71    pub(crate) fn apply_pending(&mut self, tick: ChangeTick) {
72        let Some(next) = self.pending.take() else {
73            return;
74        };
75        self.previous = Some(core::mem::replace(&mut self.current, next));
76        self.transition_tick = Some(tick);
77    }
78}
79
80/// Installs an explicit state-transition system for `State<S>`.
81pub fn apply<S: Eq + 'static>(
82    name: impl Into<alloc::string::String>,
83    stage_label: impl Into<alloc::string::String>,
84) -> crate::schedule::System {
85    let label = name.into();
86    crate::schedule::System::try_new(
87        label,
88        stage_label,
89        move |world: &mut crate::world::World, _dt| {
90            let tick = world
91                .issue_change_tick_for_state()
92                .map_err(|error| alloc::format!("{error:?}"))?;
93            let state = world
94                .resource_mut::<State<S>>()
95                .map_err(|error| alloc::format!("{error:?}"))?
96                .expect("required state resource remains present while the schedule lease is live");
97            state.apply_pending(tick);
98            Ok(())
99        },
100    )
101    .requires_resource::<State<S>>()
102}
103
104/// Creates an exit hook that runs after a transition request and before
105/// [`apply`] commits it. The hook observes the outgoing [`State::current`]
106/// and requested [`State::pending`] values.
107pub fn on_exit<S: Eq + 'static>(
108    name: impl Into<alloc::string::String>,
109    stage_label: impl Into<alloc::string::String>,
110    body: impl FnMut(&mut crate::world::World, f32) + 'static,
111) -> crate::schedule::System {
112    crate::schedule::System::new(name, stage_label, body)
113        .run_if(crate::schedule::Condition::state_pending::<S>())
114        .requires_resource::<State<S>>()
115}
116
117/// Creates a post-apply transition hook. It observes [`State::previous`] and
118/// [`State::current`] after an ordered [`apply`] system runs.
119pub fn on_transition<S: Eq + 'static>(
120    name: impl Into<alloc::string::String>,
121    stage_label: impl Into<alloc::string::String>,
122    body: impl FnMut(&mut crate::world::World, f32) + 'static,
123) -> crate::schedule::System {
124    crate::schedule::System::new(name, stage_label, body)
125        .run_if(crate::schedule::Condition::state_changed::<S>())
126        .requires_resource::<State<S>>()
127}
128
129/// Creates a post-apply enter hook. It has the same transition boundary as
130/// [`on_transition`] and is named for host lifecycle readability.
131pub fn on_enter<S: Eq + 'static>(
132    name: impl Into<alloc::string::String>,
133    stage_label: impl Into<alloc::string::String>,
134    body: impl FnMut(&mut crate::world::World, f32) + 'static,
135) -> crate::schedule::System {
136    on_transition::<S>(name, stage_label, body)
137}
138
139#[cfg(feature = "std")]
140impl core::fmt::Display for StateError {
141    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
142        match self {
143            Self::ConflictingTransition => f.write_str("conflicting state transition request"),
144        }
145    }
146}
147
148#[cfg(feature = "std")]
149impl std::error::Error for StateError {}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::time::ChangeTick;
155    #[cfg(feature = "std")]
156    use alloc::string::ToString;
157
158    #[derive(Clone, Debug, Eq, PartialEq)]
159    enum Phase {
160        A,
161        B,
162        C,
163    }
164
165    #[test]
166    fn state_request_idempotent_and_conflicting() {
167        let mut state = State::new(Phase::A);
168        state.request(Phase::B).expect("first");
169        state.request(Phase::B).expect("repeat");
170        assert!(matches!(
171            state.request(Phase::C),
172            Err(StateError::ConflictingTransition)
173        ));
174        assert_eq!(state.pending(), Some(&Phase::B));
175        #[cfg(feature = "std")]
176        assert_eq!(
177            StateError::ConflictingTransition.to_string(),
178            "conflicting state transition request"
179        );
180    }
181
182    #[test]
183    fn apply_system_wiring_requires_state_and_runs_transitions() {
184        use crate::app::AppBuilder;
185        use crate::schedule::{stage, BuildError};
186
187        #[derive(Clone, Debug, Eq, PartialEq)]
188        enum Menu {
189            Open,
190            Closed,
191        }
192
193        let mut missing = AppBuilder::new();
194        missing
195            .add_system(apply::<Menu>("apply", stage::UPDATE))
196            .expect("system");
197        assert!(matches!(
198            missing.build(),
199            Err(BuildError::MissingRequiredResource { .. })
200        ));
201
202        let mut builder = AppBuilder::new();
203        builder.insert_state(Menu::Open);
204        builder
205            .add_system(apply::<Menu>("apply", stage::UPDATE))
206            .expect("system");
207        let mut app = builder.build().expect("app");
208        app.world_mut()
209            .resource_mut::<State<Menu>>()
210            .expect("state access")
211            .expect("state resource")
212            .request(Menu::Closed)
213            .expect("request");
214        app.update(0.0).expect("update");
215        let state = app
216            .world()
217            .resource::<State<Menu>>()
218            .expect("state access")
219            .expect("state resource");
220        assert_eq!(state.current(), &Menu::Closed);
221        assert_eq!(state.previous(), Some(&Menu::Open));
222        assert!(state.pending().is_none());
223        assert!(state.transition_tick().is_some());
224    }
225
226    #[test]
227    fn apply_pending_moves_current_and_records_tick() {
228        let mut state = State::new(Phase::A);
229        state.request(Phase::B).expect("request");
230        let tick = ChangeTick::from_raw(9);
231        state.apply_pending(tick);
232        assert_eq!(state.current(), &Phase::B);
233        assert_eq!(state.previous(), Some(&Phase::A));
234        assert_eq!(state.transition_tick(), Some(tick));
235        state.apply_pending(tick);
236        assert_eq!(state.current(), &Phase::B);
237    }
238
239    #[test]
240    fn lifecycle_helpers_observe_the_ordered_transition_boundary() {
241        use alloc::rc::Rc;
242        use core::cell::RefCell;
243
244        use crate::app::AppBuilder;
245        use crate::schedule::stage;
246
247        #[derive(Debug, Eq, PartialEq)]
248        enum Mode {
249            Menu,
250            Playing,
251        }
252
253        let order = Rc::new(RefCell::new(alloc::vec::Vec::new()));
254        let exit_order = Rc::clone(&order);
255        let transition_order = Rc::clone(&order);
256        let enter_order = Rc::clone(&order);
257        let mut builder = AppBuilder::new();
258        // State lifecycle helpers also support the ordinary resource builder
259        // path; hosts need not use the `insert_state` convenience.
260        builder.insert_resource(State::new(Mode::Menu));
261        builder
262            .add_system(on_exit::<Mode>("exit", stage::UPDATE, move |world, _| {
263                let state = world
264                    .resource::<State<Mode>>()
265                    .expect("state")
266                    .expect("present");
267                assert_eq!(state.current(), &Mode::Menu);
268                assert_eq!(state.pending(), Some(&Mode::Playing));
269                exit_order.borrow_mut().push("exit");
270            }))
271            .expect("exit");
272        builder
273            .add_system(apply::<Mode>("apply", stage::UPDATE).after("exit"))
274            .expect("apply");
275        builder
276            .add_system(
277                on_transition::<Mode>("transition", stage::UPDATE, move |world, _| {
278                    let state = world
279                        .resource::<State<Mode>>()
280                        .expect("state")
281                        .expect("present");
282                    assert_eq!(state.previous(), Some(&Mode::Menu));
283                    assert_eq!(state.current(), &Mode::Playing);
284                    transition_order.borrow_mut().push("transition");
285                })
286                .after("apply"),
287            )
288            .expect("transition");
289        builder
290            .add_system(
291                on_enter::<Mode>("enter", stage::UPDATE, move |_, _| {
292                    enter_order.borrow_mut().push("enter");
293                })
294                .after("transition"),
295            )
296            .expect("enter");
297        let mut app = builder.build().expect("app");
298        app.world_mut()
299            .resource_mut::<State<Mode>>()
300            .expect("state")
301            .expect("present")
302            .request(Mode::Playing)
303            .expect("request");
304
305        app.update(0.0).expect("update");
306        assert_eq!(&*order.borrow(), &["exit", "transition", "enter"]);
307    }
308}