mecha10-behavior-runtime 0.9.0

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
//! JSON configuration types for behavior composition
//!
//! This module provides types for defining behavior trees in JSON configuration files.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// A behavior tree definition loaded from a JSON configuration file.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BehaviorConfig {
    /// URL of the JSON schema this configuration conforms to.
    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
    #[schemars(example = &"https://mecha10.dev/schemas/behavior-composition-v1.json")]
    pub schema: Option<String>,

    /// Name of this behavior.
    #[schemars(example = &"patrol_mission")]
    pub name: String,

    /// Human-readable description of what this behavior does.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[schemars(example = &"Patrol behavior with safety layer")]
    pub description: Option<String>,

    /// The root node of the behavior tree.
    pub root: CompositionConfig,

    /// Named configuration values referenced by nodes in the behavior tree via `config_ref`.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[schemars(example = HashMap::from([
        ("safety".to_string(), serde_json::json!({"max_speed": 1.0})),
        ("detector".to_string(), serde_json::json!({"confidence": 0.7})),
        ("wander".to_string(), serde_json::json!({"speed": 0.5})),
    ]))]
    pub configs: HashMap<String, serde_json::Value>,
}

/// A node in a behavior tree: either a composite control-flow node or a leaf behavior node.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CompositionConfig {
    /// Runs its children in order, stopping early if one fails.
    Sequence {
        /// Child nodes to run in order.
        children: Vec<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Runs its children in order, stopping early if one succeeds.
    Selector {
        /// Child nodes to try in order.
        children: Vec<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Runs all of its children concurrently.
    Parallel {
        /// Child nodes to run concurrently.
        children: Vec<CompositionConfig>,

        /// Policy determining when this node succeeds based on its children's outcomes.
        #[serde(default = "default_parallel_policy")]
        policy: ParallelPolicyConfig,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Like `Parallel`, but every child is re-ticked every call regardless of its previous
    /// status - no "frozen once resolved" memory. Use this for a condition that must be
    /// re-evaluated indefinitely, e.g. inside a `Subsumption` layer.
    Watch {
        /// Child nodes, all re-ticked every call.
        children: Vec<CompositionConfig>,

        /// Policy determining when this node succeeds/fails based on its children's outcomes.
        #[serde(default = "default_parallel_policy")]
        policy: ParallelPolicyConfig,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Priority-based preemption (subsumption architecture): tries each layer highest-priority
    /// first; the first to return `Running`/`Success` wins for that tick. A layer returns
    /// `Failure` to cede control to the next layer down, within the same tick.
    Subsumption {
        /// Priority layers, highest priority first (ties broken by declaration order).
        layers: Vec<SubsumptionLayerConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Negates its child's `Success`/`Failure` result; `Running` passes through unchanged.
    Inverter {
        /// The single child node to invert.
        child: Box<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Reports `Success` once its child resolves, regardless of the child's actual result;
    /// `Running` passes through unchanged. Useful for an optional/best-effort step whose
    /// failure shouldn't abort a surrounding `Sequence`.
    ForceSuccess {
        /// The single child node to force-succeed.
        child: Box<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Reports `Failure` once its child resolves, regardless of the child's actual result;
    /// `Running` passes through unchanged. The mirror of `ForceSuccess`.
    ForceFailure {
        /// The single child node to force-fail.
        child: Box<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Re-runs its child every time it succeeds, up to an optional fixed `count`; a `Failure`
    /// ends the loop immediately with `Failure`. Omit `count` to repeat indefinitely.
    Repeat {
        /// The single child node to repeat.
        child: Box<CompositionConfig>,

        /// Number of successful runs required before this node succeeds. Omit to repeat
        /// indefinitely (the node only ever stops via the child failing).
        #[serde(skip_serializing_if = "Option::is_none")]
        count: Option<u32>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Re-runs its child up to `max_attempts` times whenever it fails, succeeding as soon as one
    /// attempt succeeds; only fails once every attempt is exhausted.
    Retry {
        /// The single child node to retry.
        child: Box<CompositionConfig>,

        /// Maximum number of attempts (must be at least 1).
        #[schemars(example = &3)]
        max_attempts: u32,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Bounds how long its child may stay `Running`: fails immediately once `timeout_secs` have
    /// elapsed since this node started ticking the child (without resolving).
    Timeout {
        /// The single child node to bound.
        child: Box<CompositionConfig>,

        /// Maximum time, in seconds, the child is allowed to stay `Running`.
        #[schemars(example = &30.0)]
        timeout_secs: f64,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// A leaf node that runs a single behavior node implementation.
    Node {
        /// Name of the behavior node type to run.
        #[schemars(example = &"wander")]
        node: String,

        /// Key into the top-level `configs` map to use as this node's configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        #[schemars(example = &"wander")]
        config_ref: Option<String>,

        /// Inline configuration for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        config: Option<serde_json::Value>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },
}

fn default_parallel_policy() -> ParallelPolicyConfig {
    ParallelPolicyConfig::RequireAll
}

/// Determines when a `Parallel` composition node succeeds, based on its children's outcomes.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ParallelPolicyConfig {
    /// Succeeds only once all children succeed.
    RequireAll,

    /// Succeeds once any one child succeeds.
    RequireOne,

    /// Succeeds once at least the given number of children succeed.
    RequireN(usize),
}

impl From<ParallelPolicyConfig> for crate::composition::ParallelPolicy {
    fn from(config: ParallelPolicyConfig) -> Self {
        match config {
            ParallelPolicyConfig::RequireAll => crate::composition::ParallelPolicy::RequireAll,
            ParallelPolicyConfig::RequireOne => crate::composition::ParallelPolicy::RequireOne,
            ParallelPolicyConfig::RequireN(n) => crate::composition::ParallelPolicy::RequireN(n),
        }
    }
}

/// A single priority layer within a `Subsumption` composition node.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SubsumptionLayerConfig {
    /// Layer priority - higher wins. Layers are tried highest-first each tick.
    pub priority: u8,

    /// The composition node implementing this layer.
    pub node: CompositionConfig,

    /// Optional name for this layer, used for debugging/logging.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

/// Configuration referencing a behavior node type and its inline configuration.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct NodeConfig {
    /// Name of the behavior node type.
    #[schemars(example = &"wander")]
    pub node: String,

    /// Inline configuration for this node.
    #[serde(default)]
    pub config: serde_json::Value,
}

#[derive(Debug, Clone)]
pub struct NodeReference {
    pub node_type: String,

    pub config: serde_json::Value,

    pub name: Option<String>,
}

impl BehaviorConfig {
    /// Load a behavior configuration from a JSON string.
    pub fn from_json(json: &str) -> anyhow::Result<Self> {
        Ok(serde_json::from_str(json)?)
    }

    /// Load a behavior configuration from a JSON file.
    pub fn from_file(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
        let content = std::fs::read_to_string(path)?;
        Self::from_json(&content)
    }

    /// Convert this configuration to JSON.
    pub fn to_json(&self) -> anyhow::Result<String> {
        Ok(serde_json::to_string_pretty(self)?)
    }

    /// Generate a JSON schema for this configuration type.
    ///
    /// This can be used for editor IntelliSense and validation.
    pub fn generate_schema() -> anyhow::Result<String> {
        Ok(serde_json::to_string_pretty(&Self::schema_value())?)
    }

    /// The `schemars`-derived JSON Schema for this configuration type, as a `serde_json::Value`
    /// -- the same schema [`generate_schema`](Self::generate_schema) serializes to a
    /// pretty-printed string and `super::validator`'s runtime validator is built from.
    pub fn schema_value() -> serde_json::Value {
        let schema = schemars::schema_for!(BehaviorConfig);
        serde_json::to_value(&schema).expect("schemars schema always serializes to JSON")
    }

    /// Get a configuration value by reference key.
    pub fn get_config(&self, key: &str) -> Option<&serde_json::Value> {
        self.configs.get(key)
    }
}