Skip to main content

mecha10_behavior_runtime/actions/
timing.rs

1//! Action nodes that gate on elapsed time, or do nothing.
2
3use super::elapsed_timer::{reset_timer_field, ElapsedTimer};
4use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::time::Duration;
9
10macro_rules! from_json_with_duration {
11    ($config:ident) => {
12        /// Create a new node from JSON configuration
13        pub fn from_json(config: Value) -> anyhow::Result<Self> {
14            let cfg: $config = serde_json::from_value(config)?;
15            Ok(Self {
16                duration: Duration::from_secs_f64(cfg.duration_secs),
17                timer: ElapsedTimer::default(),
18                last_status: None,
19            })
20        }
21    };
22}
23
24#[derive(Debug, Default)]
25pub struct TimerNode {
26    duration: Duration,
27    timer: ElapsedTimer,
28    last_status: Option<NodeStatus>,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32struct TimerConfig {
33    duration_secs: f64,
34}
35
36impl TimerNode {
37    from_json_with_duration!(TimerConfig);
38}
39
40#[async_trait]
41impl BehaviorNode for TimerNode {
42    async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
43        let status = if self.timer.elapsed_since_start(self.duration) {
44            NodeStatus::Success
45        } else {
46            NodeStatus::Running
47        };
48        self.last_status = Some(status);
49        Ok(status)
50    }
51
52    reset_timer_field!(timer);
53
54    fn name(&self) -> &str {
55        "TimerNode"
56    }
57
58    fn status_snapshot(&self) -> TreeStatusSnapshot {
59        TreeStatusSnapshot {
60            name: self.name().to_string(),
61            status: self.last_status,
62            active_capability: self.active_capability().map(String::from),
63            children: Vec::new(),
64        }
65    }
66}
67
68#[derive(Debug, Default)]
69pub struct IdleNode {
70    last_status: Option<NodeStatus>,
71}
72
73impl IdleNode {
74    /// Create a new IdleNode from JSON configuration
75    pub fn from_json(_config: Value) -> anyhow::Result<Self> {
76        Ok(Self::default())
77    }
78}
79
80#[async_trait]
81impl BehaviorNode for IdleNode {
82    async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
83        self.last_status = Some(NodeStatus::Success);
84        Ok(NodeStatus::Success)
85    }
86
87    fn name(&self) -> &str {
88        "IdleNode"
89    }
90
91    fn status_snapshot(&self) -> TreeStatusSnapshot {
92        TreeStatusSnapshot {
93            name: self.name().to_string(),
94            status: self.last_status,
95            active_capability: self.active_capability().map(String::from),
96            children: Vec::new(),
97        }
98    }
99}
100
101#[derive(Debug, Default)]
102pub struct PauseNode {
103    duration: Duration,
104    timer: ElapsedTimer,
105    last_status: Option<NodeStatus>,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
109struct PauseConfig {
110    duration_secs: f64,
111}
112
113impl PauseNode {
114    from_json_with_duration!(PauseConfig);
115}
116
117#[async_trait]
118impl BehaviorNode for PauseNode {
119    async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
120        if !self.timer.is_running() {
121            tracing::debug!("PauseNode: starting pause for {:?}", self.duration);
122        }
123
124        let status = if self.timer.elapsed_since_start(self.duration) {
125            tracing::debug!("PauseNode: pause complete");
126            NodeStatus::Success
127        } else {
128            NodeStatus::Running
129        };
130        self.last_status = Some(status);
131        Ok(status)
132    }
133
134    reset_timer_field!(timer);
135
136    fn name(&self) -> &str {
137        "PauseNode"
138    }
139
140    fn status_snapshot(&self) -> TreeStatusSnapshot {
141        TreeStatusSnapshot {
142            name: self.name().to_string(),
143            status: self.last_status,
144            active_capability: self.active_capability().map(String::from),
145            children: Vec::new(),
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    /// Before any `tick()`, a leaf's `status_snapshot()` must report `status: None` (the
155    /// trait-default behavior every leaf override still has to preserve for a never-ticked node)
156    /// and no children.
157    #[test]
158    fn timer_node_status_snapshot_is_none_before_any_tick() {
159        let node = TimerNode::from_json(serde_json::json!({ "duration_secs": 60.0 })).unwrap();
160        let snapshot = node.status_snapshot();
161        assert_eq!(snapshot.status, None);
162        assert!(snapshot.children.is_empty());
163    }
164
165    /// After `tick()`, `status_snapshot()` must report exactly the `NodeStatus` `tick()` itself
166    /// returned - the core contract this leaf's `last_status` field exists to support.
167    #[tokio::test]
168    #[ignore = "Requires Redis infrastructure"]
169    async fn timer_node_status_snapshot_matches_the_last_tick_result() {
170        let ctx = crate::test_helpers::create_test_context("timer_node_status_snapshot").await;
171        let mut node = TimerNode::from_json(serde_json::json!({ "duration_secs": 0.0 })).unwrap();
172
173        let status = node.tick(&ctx).await.unwrap();
174        assert_eq!(status, NodeStatus::Success);
175
176        let snapshot = node.status_snapshot();
177        assert_eq!(snapshot.status, Some(status));
178        assert_eq!(snapshot.name, "TimerNode");
179        assert!(snapshot.children.is_empty());
180    }
181}