mecha10-behavior-runtime 4.2.11

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
//! Action nodes that gate on elapsed time, or do nothing.

use super::elapsed_timer::{reset_timer_field, ElapsedTimer};
use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;

macro_rules! from_json_with_duration {
    ($config:ident) => {
        /// Create a new node from JSON configuration
        pub fn from_json(config: Value) -> anyhow::Result<Self> {
            let cfg: $config = serde_json::from_value(config)?;
            Ok(Self {
                duration: Duration::from_secs_f64(cfg.duration_secs),
                timer: ElapsedTimer::default(),
                last_status: None,
            })
        }
    };
}

#[derive(Debug, Default)]
pub struct TimerNode {
    duration: Duration,
    timer: ElapsedTimer,
    last_status: Option<NodeStatus>,
}

#[derive(Debug, Serialize, Deserialize)]
struct TimerConfig {
    duration_secs: f64,
}

impl TimerNode {
    from_json_with_duration!(TimerConfig);
}

#[async_trait]
impl BehaviorNode for TimerNode {
    async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
        let status = if self.timer.elapsed_since_start(self.duration) {
            NodeStatus::Success
        } else {
            NodeStatus::Running
        };
        self.last_status = Some(status);
        Ok(status)
    }

    reset_timer_field!(timer);

    fn name(&self) -> &str {
        "TimerNode"
    }

    fn status_snapshot(&self) -> TreeStatusSnapshot {
        TreeStatusSnapshot {
            name: self.name().to_string(),
            status: self.last_status,
            active_capability: self.active_capability().map(String::from),
            children: Vec::new(),
        }
    }
}

#[derive(Debug, Default)]
pub struct IdleNode {
    last_status: Option<NodeStatus>,
}

impl IdleNode {
    /// Create a new IdleNode from JSON configuration
    pub fn from_json(_config: Value) -> anyhow::Result<Self> {
        Ok(Self::default())
    }
}

#[async_trait]
impl BehaviorNode for IdleNode {
    async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
        self.last_status = Some(NodeStatus::Success);
        Ok(NodeStatus::Success)
    }

    fn name(&self) -> &str {
        "IdleNode"
    }

    fn status_snapshot(&self) -> TreeStatusSnapshot {
        TreeStatusSnapshot {
            name: self.name().to_string(),
            status: self.last_status,
            active_capability: self.active_capability().map(String::from),
            children: Vec::new(),
        }
    }
}

#[derive(Debug, Default)]
pub struct PauseNode {
    duration: Duration,
    timer: ElapsedTimer,
    last_status: Option<NodeStatus>,
}

#[derive(Debug, Serialize, Deserialize)]
struct PauseConfig {
    duration_secs: f64,
}

impl PauseNode {
    from_json_with_duration!(PauseConfig);
}

#[async_trait]
impl BehaviorNode for PauseNode {
    async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
        if !self.timer.is_running() {
            tracing::debug!("PauseNode: starting pause for {:?}", self.duration);
        }

        let status = if self.timer.elapsed_since_start(self.duration) {
            tracing::debug!("PauseNode: pause complete");
            NodeStatus::Success
        } else {
            NodeStatus::Running
        };
        self.last_status = Some(status);
        Ok(status)
    }

    reset_timer_field!(timer);

    fn name(&self) -> &str {
        "PauseNode"
    }

    fn status_snapshot(&self) -> TreeStatusSnapshot {
        TreeStatusSnapshot {
            name: self.name().to_string(),
            status: self.last_status,
            active_capability: self.active_capability().map(String::from),
            children: Vec::new(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Before any `tick()`, a leaf's `status_snapshot()` must report `status: None` (the
    /// trait-default behavior every leaf override still has to preserve for a never-ticked node)
    /// and no children.
    #[test]
    fn timer_node_status_snapshot_is_none_before_any_tick() {
        let node = TimerNode::from_json(serde_json::json!({ "duration_secs": 60.0 })).unwrap();
        let snapshot = node.status_snapshot();
        assert_eq!(snapshot.status, None);
        assert!(snapshot.children.is_empty());
    }

    /// After `tick()`, `status_snapshot()` must report exactly the `NodeStatus` `tick()` itself
    /// returned - the core contract this leaf's `last_status` field exists to support.
    #[tokio::test]
    #[ignore = "Requires Redis infrastructure"]
    async fn timer_node_status_snapshot_matches_the_last_tick_result() {
        let ctx = crate::test_helpers::create_test_context("timer_node_status_snapshot").await;
        let mut node = TimerNode::from_json(serde_json::json!({ "duration_secs": 0.0 })).unwrap();

        let status = node.tick(&ctx).await.unwrap();
        assert_eq!(status, NodeStatus::Success);

        let snapshot = node.status_snapshot();
        assert_eq!(snapshot.status, Some(status));
        assert_eq!(snapshot.name, "TimerNode");
        assert!(snapshot.children.is_empty());
    }
}