Skip to main content

mecha10_behavior_runtime/
loader.rs

1//! Behavior tree loader for creating trees from JSON configuration
2//!
3//! This module provides the `BehaviorLoader` for loading behavior trees from JSON configuration.
4
5use crate::composition::{
6    ConditionNode, CooldownNode, ForceFailureNode, ForceSuccessNode, InverterNode, ParallelNode, RepeatNode, RetryNode,
7    SelectOrNode, SequenceNode, SubsumptionNode, TimeoutNode, WatchNode, WeightedSelectNode,
8};
9use crate::config::{BehaviorConfig, CompositionConfig};
10use crate::registry::NodeRegistry;
11use crate::BoxedBehavior;
12use anyhow::{anyhow, Context as AnyhowContext, Result};
13use serde_json::Value;
14use std::path::{Path, PathBuf};
15use tracing::{debug, info};
16
17#[derive(Clone)]
18pub struct BehaviorLoader {
19    registry: NodeRegistry,
20}
21
22impl BehaviorLoader {
23    /// Create a new behavior loader with the given node registry.
24    pub fn new(registry: NodeRegistry) -> Self {
25        Self { registry }
26    }
27
28    /// Load a behavior tree from a `BehaviorConfig`, recursively instantiating all nodes.
29    ///
30    /// No base directory is available to resolve a `CompositionConfig::Include` node's `path`
31    /// against - if the tree contains one, loading fails. Use [`load_from_file`](Self::load_from_file)
32    /// for a tree that uses `include`.
33    pub fn load(&self, config: &BehaviorConfig) -> Result<BoxedBehavior> {
34        self.load_with_base_dir(config, None)
35    }
36
37    /// Load a behavior tree from a JSON string (parses then loads). Same `include`-path caveat
38    /// as [`load`](Self::load) - there's no file on disk to resolve a fragment path relative to.
39    pub fn load_from_json(&self, json: &str) -> Result<BoxedBehavior> {
40        self.load(&BehaviorConfig::from_json(json)?)
41    }
42
43    /// Load a behavior tree from a JSON file (reads then loads). Unlike [`load`](Self::load),
44    /// any `CompositionConfig::Include` node in the tree resolves its `path` relative to this
45    /// file's own parent directory.
46    pub fn load_from_file(&self, path: impl AsRef<Path>) -> Result<BoxedBehavior> {
47        let path = path.as_ref();
48        let config = BehaviorConfig::from_file(path)
49            .with_context(|| format!("Failed to read behavior config from {}", path.display()))?;
50        let base_dir = path.parent().map(Path::to_path_buf);
51        self.load_with_base_dir(&config, base_dir.as_deref())
52    }
53
54    fn load_with_base_dir(&self, config: &BehaviorConfig, base_dir: Option<&Path>) -> Result<BoxedBehavior> {
55        info!("Loading behavior tree: {}", config.name);
56        if let Some(desc) = &config.description {
57            debug!("Description: {}", desc);
58        }
59
60        self.load_composition(&config.root, config, base_dir)
61            .with_context(|| format!("Failed to load behavior tree '{}'", config.name))
62    }
63
64    /// Recursively load a composition node and its children. `base_dir`, when present, is the
65    /// directory an `include` node's `path` is resolved relative to - it changes as loading
66    /// descends into an included fragment file, so a fragment can itself `include` another
67    /// fragment relative to its own location.
68    fn load_composition(
69        &self,
70        composition: &CompositionConfig,
71        config: &BehaviorConfig,
72        base_dir: Option<&Path>,
73    ) -> Result<BoxedBehavior> {
74        match composition {
75            CompositionConfig::Sequence { children, name } => {
76                debug!(
77                    "Loading sequence node{}",
78                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default()
79                );
80
81                let child_nodes = children
82                    .iter()
83                    .enumerate()
84                    .map(|(i, child)| {
85                        self.load_composition(child, config, base_dir)
86                            .with_context(|| format!("Failed to load sequence child {}", i))
87                    })
88                    .collect::<Result<Vec<_>>>()?;
89
90                debug!("Sequence node loaded with {} children", child_nodes.len());
91                Ok(Box::new(SequenceNode::new(child_nodes)))
92            }
93
94            CompositionConfig::Selector { children, name } => {
95                debug!(
96                    "Loading selector node{}",
97                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default()
98                );
99
100                let child_nodes = children
101                    .iter()
102                    .enumerate()
103                    .map(|(i, child)| {
104                        self.load_composition(child, config, base_dir)
105                            .with_context(|| format!("Failed to load selector child {}", i))
106                    })
107                    .collect::<Result<Vec<_>>>()?;
108
109                debug!("Selector node loaded with {} children", child_nodes.len());
110                Ok(Box::new(SelectOrNode::new(child_nodes)))
111            }
112
113            CompositionConfig::WeightedSelect { children, name } => {
114                debug!(
115                    "Loading weighted_select node{} with {} children",
116                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
117                    children.len()
118                );
119
120                let weighted_children = children
121                    .iter()
122                    .enumerate()
123                    .map(|(i, weighted_child)| {
124                        let child_node = self
125                            .load_composition(&weighted_child.node, config, base_dir)
126                            .with_context(|| format!("Failed to load weighted_select child {}", i))?;
127                        Ok((weighted_child.weight, child_node))
128                    })
129                    .collect::<Result<Vec<_>>>()?;
130
131                debug!("WeightedSelect node loaded with {} children", weighted_children.len());
132                Ok(Box::new(WeightedSelectNode::new(weighted_children)))
133            }
134
135            CompositionConfig::Parallel { children, policy, name } => {
136                debug!(
137                    "Loading parallel node{} with policy {:?}",
138                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
139                    policy
140                );
141
142                let child_nodes = children
143                    .iter()
144                    .enumerate()
145                    .map(|(i, child)| {
146                        self.load_composition(child, config, base_dir)
147                            .with_context(|| format!("Failed to load parallel child {}", i))
148                    })
149                    .collect::<Result<Vec<_>>>()?;
150
151                debug!("Parallel node loaded with {} children", child_nodes.len());
152                Ok(Box::new(ParallelNode::new(child_nodes, policy.clone().into())))
153            }
154
155            CompositionConfig::Watch { children, policy, name } => {
156                debug!(
157                    "Loading watch node{} with policy {:?}",
158                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
159                    policy
160                );
161
162                let child_nodes = children
163                    .iter()
164                    .enumerate()
165                    .map(|(i, child)| {
166                        self.load_composition(child, config, base_dir)
167                            .with_context(|| format!("Failed to load watch child {}", i))
168                    })
169                    .collect::<Result<Vec<_>>>()?;
170
171                debug!("Watch node loaded with {} children", child_nodes.len());
172                Ok(Box::new(WatchNode::new(child_nodes, policy.clone().into())))
173            }
174
175            CompositionConfig::Subsumption { layers, name } => {
176                debug!(
177                    "Loading subsumption node{} with {} layers",
178                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
179                    layers.len()
180                );
181
182                let mut node = SubsumptionNode::default();
183                for (i, layer) in layers.iter().enumerate() {
184                    let behavior = self
185                        .load_composition(&layer.node, config, base_dir)
186                        .with_context(|| format!("Failed to load subsumption layer {}", i))?;
187                    let mut subsumption_layer = crate::composition::SubsumptionLayer::new(layer.priority, behavior);
188                    if let Some(layer_name) = &layer.name {
189                        subsumption_layer = subsumption_layer.with_name(layer_name.clone());
190                    }
191                    node.layers.push(subsumption_layer);
192                }
193                node.layers.sort_by_key(|l| std::cmp::Reverse(l.priority));
194
195                debug!("Subsumption node loaded with {} layers", node.layers.len());
196                Ok(Box::new(node))
197            }
198
199            CompositionConfig::Inverter { child, name } => {
200                debug!(
201                    "Loading inverter node{}",
202                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default()
203                );
204
205                let child_node = self
206                    .load_composition(child, config, base_dir)
207                    .with_context(|| "Failed to load inverter child")?;
208                Ok(Box::new(InverterNode::new(child_node)))
209            }
210
211            CompositionConfig::ForceSuccess { child, name } => {
212                debug!(
213                    "Loading force_success node{}",
214                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default()
215                );
216
217                let child_node = self
218                    .load_composition(child, config, base_dir)
219                    .with_context(|| "Failed to load force_success child")?;
220                Ok(Box::new(ForceSuccessNode::new(child_node)))
221            }
222
223            CompositionConfig::ForceFailure { child, name } => {
224                debug!(
225                    "Loading force_failure node{}",
226                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default()
227                );
228
229                let child_node = self
230                    .load_composition(child, config, base_dir)
231                    .with_context(|| "Failed to load force_failure child")?;
232                Ok(Box::new(ForceFailureNode::new(child_node)))
233            }
234
235            CompositionConfig::Repeat { child, count, name } => {
236                debug!(
237                    "Loading repeat node{} (count: {:?})",
238                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
239                    count
240                );
241
242                let child_node = self
243                    .load_composition(child, config, base_dir)
244                    .with_context(|| "Failed to load repeat child")?;
245                Ok(Box::new(RepeatNode::new(child_node, *count)))
246            }
247
248            CompositionConfig::Retry {
249                child,
250                max_attempts,
251                name,
252            } => {
253                debug!(
254                    "Loading retry node{} (max_attempts: {})",
255                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
256                    max_attempts
257                );
258
259                let child_node = self
260                    .load_composition(child, config, base_dir)
261                    .with_context(|| "Failed to load retry child")?;
262                Ok(Box::new(RetryNode::new(child_node, *max_attempts)))
263            }
264
265            CompositionConfig::Timeout {
266                child,
267                timeout_secs,
268                name,
269            } => {
270                debug!(
271                    "Loading timeout node{} (timeout_secs: {})",
272                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
273                    timeout_secs
274                );
275
276                let child_node = self
277                    .load_composition(child, config, base_dir)
278                    .with_context(|| "Failed to load timeout child")?;
279                Ok(Box::new(TimeoutNode::new(
280                    child_node,
281                    std::time::Duration::from_secs_f64(timeout_secs.max(0.0)),
282                )))
283            }
284
285            CompositionConfig::Cooldown {
286                child,
287                cooldown_secs,
288                name,
289            } => {
290                debug!(
291                    "Loading cooldown node{} (cooldown_secs: {})",
292                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
293                    cooldown_secs
294                );
295
296                let child_node = self
297                    .load_composition(child, config, base_dir)
298                    .with_context(|| "Failed to load cooldown child")?;
299                Ok(Box::new(CooldownNode::new(
300                    child_node,
301                    std::time::Duration::from_secs_f64(cooldown_secs.max(0.0)),
302                )))
303            }
304
305            CompositionConfig::Condition { condition, child, name } => {
306                debug!(
307                    "Loading condition node{} ({:?})",
308                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
309                    condition
310                );
311
312                let child_node = self
313                    .load_composition(child, config, base_dir)
314                    .with_context(|| "Failed to load condition child")?;
315                Ok(Box::new(ConditionNode::new(condition.clone().into(), child_node)))
316            }
317
318            CompositionConfig::Include { path, name } => {
319                debug!(
320                    "Loading include node{} (path: {})",
321                    name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default(),
322                    path
323                );
324
325                let dir = base_dir.ok_or_else(|| {
326                    anyhow!(
327                        "Cannot resolve 'include' path '{}' - the behavior tree was not loaded from a file \
328                         (use BehaviorLoader::load_from_file, not load/load_from_json, for a tree that uses 'include')",
329                        path
330                    )
331                })?;
332
333                let fragment_path: PathBuf = dir.join(path);
334                let fragment_content = std::fs::read_to_string(&fragment_path)
335                    .with_context(|| format!("Failed to read include fragment '{}'", fragment_path.display()))?;
336                let fragment: CompositionConfig = serde_json::from_str(&fragment_content).with_context(|| {
337                    format!(
338                        "Failed to parse include fragment '{}' as a CompositionConfig",
339                        fragment_path.display()
340                    )
341                })?;
342
343                let fragment_base_dir = fragment_path.parent().map(Path::to_path_buf);
344                self.load_composition(&fragment, config, fragment_base_dir.as_deref())
345                    .with_context(|| format!("Failed to load include fragment '{}'", fragment_path.display()))
346            }
347
348            CompositionConfig::Node {
349                node,
350                config_ref,
351                config: inline_config,
352                name,
353            } => {
354                debug!(
355                    "Loading leaf node '{}' (type: {}){}",
356                    name.as_ref().unwrap_or(node),
357                    node,
358                    config_ref
359                        .as_ref()
360                        .map(|r| format!(" with config_ref '{}'", r))
361                        .unwrap_or_default()
362                );
363
364                let node_config = if let Some(inline) = inline_config {
365                    debug!("Using inline config for node '{}'", node);
366                    inline.clone()
367                } else if let Some(ref_key) = config_ref {
368                    config
369                        .get_config(ref_key)
370                        .ok_or_else(|| {
371                            anyhow!(
372                                "Config reference '{}' not found in configs map for node '{}'",
373                                ref_key,
374                                node
375                            )
376                        })?
377                        .clone()
378                } else {
379                    debug!("No config provided for node '{}'", node);
380                    Value::Null
381                };
382
383                self.registry
384                    .create(node, node_config)
385                    .with_context(|| format!("Failed to create node '{}'", node))
386            }
387        }
388    }
389
390    /// Get a reference to the underlying node registry.
391    pub fn registry(&self) -> &NodeRegistry {
392        &self.registry
393    }
394}
395
396impl std::fmt::Debug for BehaviorLoader {
397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398        f.debug_struct("BehaviorLoader")
399            .field("registry", &self.registry)
400            .finish()
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    /// `find_target`/`patrol` both `include` `_shared/emergency_stop_interrupt.json` as their
409    /// highest-priority (255) subsumption layer. Unlike `validate_behavior_composition_json`
410    /// (bare JSON Schema check) or `validate_behavior_config` (doesn't recurse into `include`
411    /// fragments - see `config/validator.rs`'s `"include"` match arm), actually loading the real
412    /// seed behavior file via `load_from_file` resolves the fragment relative to its directory
413    /// and instantiates every node through the registry, so an unregistered `estop_active`/
414    /// `stop` node type, a typo in the fragment path, or a schema mismatch in
415    /// `emergency_stop_interrupt.json` would fail loudly here - no Redis/Context required since
416    /// construction (unlike `tick()`) never touches messaging.
417    fn load_real_seed_behavior(name: &str) -> BoxedBehavior {
418        let mut registry = NodeRegistry::new();
419        crate::register_builtin_actions(&mut registry);
420        let loader = BehaviorLoader::new(registry);
421
422        let config_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
423            .join("tests/fixtures/seed_behaviors")
424            .join(name)
425            .join("config.json");
426
427        loader
428            .load_from_file(&config_path)
429            .unwrap_or_else(|e| panic!("{} should load: {:?}", config_path.display(), e))
430    }
431
432    #[test]
433    fn find_target_seed_behavior_loads_with_the_emergency_stop_interrupt_layer() {
434        load_real_seed_behavior("find_target");
435    }
436
437    #[test]
438    fn patrol_seed_behavior_loads_with_the_emergency_stop_interrupt_layer() {
439        load_real_seed_behavior("patrol");
440    }
441}