Skip to main content

mecha10_behavior_runtime/
composition.rs

1//! Composition primitives for building behavior trees
2//!
3//! Includes Sequence, Selector, and Parallel, plus single-child decorators (Inverter,
4
5use crate::{BehaviorNode, BoxedBehavior, NodeStatus, TreeStatusSnapshot};
6use async_trait::async_trait;
7use mecha10_core::config::{controller_mode, ControllerMode};
8use mecha10_core::Context;
9use rand::rngs::StdRng;
10use rand::{RngExt, SeedableRng};
11use std::time::{Duration, Instant};
12use tracing::{debug, info, warn};
13
14#[derive(Debug)]
15pub struct SequenceNode {
16    children: Vec<BoxedBehavior>,
17    current_index: usize,
18    last_status: Option<NodeStatus>,
19}
20
21impl SequenceNode {
22    /// Create a new sequence node with the given children.
23    pub fn new(children: Vec<BoxedBehavior>) -> Self {
24        Self {
25            children,
26            current_index: 0,
27            last_status: None,
28        }
29    }
30
31    /// Add a child behavior to the sequence.
32    pub fn add_child(&mut self, child: BoxedBehavior) {
33        self.children.push(child);
34    }
35
36    /// Get the number of children.
37    pub fn len(&self) -> usize {
38        self.children.len()
39    }
40
41    /// Check if the sequence is empty.
42    pub fn is_empty(&self) -> bool {
43        self.children.is_empty()
44    }
45}
46
47#[async_trait]
48impl BehaviorNode for SequenceNode {
49    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
50        if self.children.is_empty() {
51            self.last_status = Some(NodeStatus::Success);
52            return Ok(NodeStatus::Success);
53        }
54
55        let total_children = self.children.len();
56        while self.current_index < total_children {
57            debug!(
58                "Sequence: ticking child {} of {}",
59                self.current_index + 1,
60                total_children
61            );
62
63            let child = &mut self.children[self.current_index];
64            match child.tick(ctx).await? {
65                NodeStatus::Success => {
66                    self.current_index += 1;
67                }
68                NodeStatus::Failure => {
69                    debug!("Sequence: child {} failed", self.current_index + 1);
70                    self.last_status = Some(NodeStatus::Failure);
71                    return Ok(NodeStatus::Failure);
72                }
73                NodeStatus::Running => {
74                    self.last_status = Some(NodeStatus::Running);
75                    return Ok(NodeStatus::Running);
76                }
77            }
78        }
79
80        debug!("Sequence: all children succeeded");
81        self.last_status = Some(NodeStatus::Success);
82        Ok(NodeStatus::Success)
83    }
84
85    crate::reset_index_field!(current_index);
86
87    fn name(&self) -> &str {
88        "sequence"
89    }
90
91    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
92        crate::boxed_children_mut(self.children.iter_mut())
93    }
94
95    fn active_capability(&self) -> Option<&str> {
96        self.children
97            .get(self.current_index)
98            .and_then(|c| c.active_capability())
99    }
100
101    fn status_snapshot(&self) -> TreeStatusSnapshot {
102        TreeStatusSnapshot {
103            name: self.name().to_string(),
104            status: self.last_status,
105            active_capability: self.active_capability().map(String::from),
106            children: self.children.iter().map(|c| c.status_snapshot()).collect(),
107        }
108    }
109}
110
111#[derive(Debug)]
112pub struct SelectOrNode {
113    children: Vec<BoxedBehavior>,
114    current_index: usize,
115    last_status: Option<NodeStatus>,
116}
117
118impl SelectOrNode {
119    /// Create a new selector node with the given children.
120    pub fn new(children: Vec<BoxedBehavior>) -> Self {
121        Self {
122            children,
123            current_index: 0,
124            last_status: None,
125        }
126    }
127
128    /// Add a child behavior to the selector.
129    pub fn add_child(&mut self, child: BoxedBehavior) {
130        self.children.push(child);
131    }
132
133    /// Get the number of children.
134    pub fn len(&self) -> usize {
135        self.children.len()
136    }
137
138    /// Check if the selector is empty.
139    pub fn is_empty(&self) -> bool {
140        self.children.is_empty()
141    }
142}
143
144#[async_trait]
145impl BehaviorNode for SelectOrNode {
146    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
147        if self.children.is_empty() {
148            self.last_status = Some(NodeStatus::Failure);
149            return Ok(NodeStatus::Failure);
150        }
151
152        let total_children = self.children.len();
153        while self.current_index < total_children {
154            debug!(
155                "Selector: ticking child {} of {}",
156                self.current_index + 1,
157                total_children
158            );
159
160            let child = &mut self.children[self.current_index];
161            match child.tick(ctx).await? {
162                NodeStatus::Success => {
163                    debug!("Selector: child {} succeeded", self.current_index + 1);
164                    self.last_status = Some(NodeStatus::Success);
165                    return Ok(NodeStatus::Success);
166                }
167                NodeStatus::Failure => {
168                    warn!("Selector: child {} failed, trying next", self.current_index + 1);
169                    self.current_index += 1;
170                }
171                NodeStatus::Running => {
172                    self.last_status = Some(NodeStatus::Running);
173                    return Ok(NodeStatus::Running);
174                }
175            }
176        }
177
178        debug!("Selector: all children failed");
179        self.last_status = Some(NodeStatus::Failure);
180        Ok(NodeStatus::Failure)
181    }
182
183    crate::reset_index_field!(current_index);
184
185    fn name(&self) -> &str {
186        "selector"
187    }
188
189    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
190        crate::boxed_children_mut(self.children.iter_mut())
191    }
192
193    fn active_capability(&self) -> Option<&str> {
194        self.children
195            .get(self.current_index)
196            .and_then(|c| c.active_capability())
197    }
198
199    fn status_snapshot(&self) -> TreeStatusSnapshot {
200        TreeStatusSnapshot {
201            name: self.name().to_string(),
202            status: self.last_status,
203            active_capability: self.active_capability().map(String::from),
204            children: self.children.iter().map(|c| c.status_snapshot()).collect(),
205        }
206    }
207}
208
209/// Like [`SelectOrNode`] (ticks children until one returns non-`Failure`), but instead of always
210/// trying children in a fixed index order, each tick-cycle picks the next untried child via a
211/// weighted-random draw among children not yet tried THIS resolution cycle. A weight of `0.0` (or
212/// negative) means that child is never picked. Once a child is chosen it is "sticky": while it
213/// reports `Running`, the next `tick()` re-ticks that same child rather than re-rolling - only
214/// once it resolves does the node either return (on `Success`) or roll again among the remaining
215/// untried children (on `Failure`), mirroring `SelectOrNode::current_index`'s "resume where we
216/// left off" behavior, just with a random rather than fixed starting point each cycle.
217#[derive(Debug)]
218pub struct WeightedSelectNode {
219    children: Vec<BoxedBehavior>,
220    weights: Vec<f64>,
221    /// Which children have already been tried (and failed) this resolution cycle - cleared
222    /// whenever the node resolves (`Success`, or `Failure` once every eligible child is
223    /// exhausted).
224    tried: Vec<bool>,
225    /// The child currently being ticked, if one was chosen and hasn't resolved yet. `None` means
226    /// the next `tick()` must roll for a new child.
227    current_index: Option<usize>,
228    rng: StdRng,
229    last_status: Option<NodeStatus>,
230}
231
232impl WeightedSelectNode {
233    /// Create a new weighted selector from `(weight, child)` pairs, using a freshly OS-seeded
234    /// RNG. Negative weights are clamped to `0.0` (never picked).
235    pub fn new(weighted_children: Vec<(f64, BoxedBehavior)>) -> Self {
236        Self::with_rng(weighted_children, StdRng::from_rng(&mut rand::rng()))
237    }
238
239    /// Create a new weighted selector using the given RNG - the injection point tests use for
240    /// deterministic behavior (e.g. `StdRng::seed_from_u64(seed)`).
241    pub fn with_rng(weighted_children: Vec<(f64, BoxedBehavior)>, rng: StdRng) -> Self {
242        let mut weights = Vec::with_capacity(weighted_children.len());
243        let mut children = Vec::with_capacity(weighted_children.len());
244        for (weight, child) in weighted_children {
245            weights.push(weight.max(0.0));
246            children.push(child);
247        }
248        let tried = vec![false; children.len()];
249
250        Self {
251            children,
252            weights,
253            tried,
254            current_index: None,
255            rng,
256            last_status: None,
257        }
258    }
259
260    /// Get the number of children.
261    pub fn len(&self) -> usize {
262        self.children.len()
263    }
264
265    /// Check if the selector is empty.
266    pub fn is_empty(&self) -> bool {
267        self.children.is_empty()
268    }
269
270    /// Weighted-random draw among children that are both untried this cycle and have a positive
271    /// weight - `None` once no eligible child remains.
272    fn pick_next(&mut self) -> Option<usize> {
273        let candidates: Vec<usize> = (0..self.children.len())
274            .filter(|&i| !self.tried[i] && self.weights[i] > 0.0)
275            .collect();
276
277        let total: f64 = candidates.iter().map(|&i| self.weights[i]).sum();
278        if candidates.is_empty() || total <= 0.0 {
279            return None;
280        }
281
282        let mut draw = self.rng.random::<f64>() * total;
283        for &i in &candidates {
284            draw -= self.weights[i];
285            if draw <= 0.0 {
286                return Some(i);
287            }
288        }
289
290        // Floating-point rounding may leave a tiny positive remainder; fall back to the last
291        // candidate rather than returning None and spuriously failing the selection.
292        candidates.last().copied()
293    }
294}
295
296#[async_trait]
297impl BehaviorNode for WeightedSelectNode {
298    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
299        if self.children.is_empty() {
300            self.last_status = Some(NodeStatus::Failure);
301            return Ok(NodeStatus::Failure);
302        }
303
304        loop {
305            let idx = match self.current_index {
306                Some(idx) => idx,
307                None => match self.pick_next() {
308                    Some(idx) => idx,
309                    None => {
310                        debug!("WeightedSelect: no eligible child remains, failing");
311                        self.tried.iter_mut().for_each(|t| *t = false);
312                        self.last_status = Some(NodeStatus::Failure);
313                        return Ok(NodeStatus::Failure);
314                    }
315                },
316            };
317
318            self.current_index = Some(idx);
319            self.tried[idx] = true;
320
321            let child = &mut self.children[idx];
322            match child.tick(ctx).await? {
323                NodeStatus::Success => {
324                    debug!("WeightedSelect: child {} succeeded", idx);
325                    self.current_index = None;
326                    self.tried.iter_mut().for_each(|t| *t = false);
327                    self.last_status = Some(NodeStatus::Success);
328                    return Ok(NodeStatus::Success);
329                }
330                NodeStatus::Failure => {
331                    debug!("WeightedSelect: child {} failed, rolling again", idx);
332                    self.current_index = None;
333                }
334                NodeStatus::Running => {
335                    self.last_status = Some(NodeStatus::Running);
336                    return Ok(NodeStatus::Running);
337                }
338            }
339        }
340    }
341
342    fn reset_self(&mut self) {
343        self.current_index = None;
344        self.tried.iter_mut().for_each(|t| *t = false);
345    }
346
347    fn name(&self) -> &str {
348        "weighted_select"
349    }
350
351    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
352        crate::boxed_children_mut(self.children.iter_mut())
353    }
354
355    fn active_capability(&self) -> Option<&str> {
356        self.current_index
357            .and_then(|i| self.children.get(i))
358            .and_then(|c| c.active_capability())
359    }
360
361    fn status_snapshot(&self) -> TreeStatusSnapshot {
362        TreeStatusSnapshot {
363            name: self.name().to_string(),
364            status: self.last_status,
365            active_capability: self.active_capability().map(String::from),
366            children: self.children.iter().map(|c| c.status_snapshot()).collect(),
367        }
368    }
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum ParallelPolicy {
373    RequireAll,
374
375    RequireOne,
376
377    RequireN(usize),
378}
379
380#[derive(Debug)]
381pub struct ParallelNode {
382    children: Vec<BoxedBehavior>,
383    policy: ParallelPolicy,
384    child_statuses: Vec<NodeStatus>,
385    last_status: Option<NodeStatus>,
386}
387
388impl ParallelNode {
389    /// Create a new parallel node with the given children and policy.
390    pub fn new(children: Vec<BoxedBehavior>, policy: ParallelPolicy) -> Self {
391        let child_statuses = vec![NodeStatus::Running; children.len()];
392        Self {
393            children,
394            policy,
395            child_statuses,
396            last_status: None,
397        }
398    }
399
400    /// Add a child behavior to the parallel node.
401    pub fn add_child(&mut self, child: BoxedBehavior) {
402        self.children.push(child);
403        self.child_statuses.push(NodeStatus::Running);
404    }
405
406    /// Get the number of children.
407    pub fn len(&self) -> usize {
408        self.children.len()
409    }
410
411    /// Check if the parallel node is empty.
412    pub fn is_empty(&self) -> bool {
413        self.children.is_empty()
414    }
415}
416
417#[async_trait]
418impl BehaviorNode for ParallelNode {
419    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
420        if self.children.is_empty() {
421            self.last_status = Some(NodeStatus::Success);
422            return Ok(NodeStatus::Success);
423        }
424
425        for (i, child) in self.children.iter_mut().enumerate() {
426            if self.child_statuses[i].is_running() {
427                let status = child.tick(ctx).await?;
428                self.child_statuses[i] = status;
429                debug!("Parallel: child {} status: {}", i + 1, status);
430            }
431        }
432
433        let success_count = self.child_statuses.iter().filter(|s| s.is_success()).count();
434        let failure_count = self.child_statuses.iter().filter(|s| s.is_failure()).count();
435        let _running_count = self.child_statuses.iter().filter(|s| s.is_running()).count();
436
437        let status = match self.policy {
438            ParallelPolicy::RequireAll => {
439                if failure_count > 0 {
440                    debug!("Parallel (RequireAll): at least one child failed");
441                    NodeStatus::Failure
442                } else if success_count == self.children.len() {
443                    debug!("Parallel (RequireAll): all children succeeded");
444                    NodeStatus::Success
445                } else {
446                    NodeStatus::Running
447                }
448            }
449            ParallelPolicy::RequireOne => {
450                if success_count > 0 {
451                    debug!("Parallel (RequireOne): at least one child succeeded");
452                    NodeStatus::Success
453                } else if failure_count == self.children.len() {
454                    debug!("Parallel (RequireOne): all children failed");
455                    NodeStatus::Failure
456                } else {
457                    NodeStatus::Running
458                }
459            }
460            ParallelPolicy::RequireN(n) => {
461                if success_count >= n {
462                    debug!("Parallel (RequireN({})): {} children succeeded", n, success_count);
463                    NodeStatus::Success
464                } else if (self.children.len() - failure_count) < n {
465                    debug!("Parallel (RequireN({})): not enough children can succeed", n);
466                    NodeStatus::Failure
467                } else {
468                    NodeStatus::Running
469                }
470            }
471        };
472
473        self.last_status = Some(status);
474        Ok(status)
475    }
476
477    crate::reset_child_statuses_field!(child_statuses);
478
479    fn name(&self) -> &str {
480        "parallel"
481    }
482
483    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
484        crate::boxed_children_mut(self.children.iter_mut())
485    }
486
487    /// The first still-`Running` child's capability, if any - "first" is an arbitrary but
488    /// deterministic tie-break since at most one running child is expected to declare one.
489    fn active_capability(&self) -> Option<&str> {
490        self.children
491            .iter()
492            .zip(self.child_statuses.iter())
493            .find(|(_, status)| status.is_running())
494            .and_then(|(child, _)| child.active_capability())
495    }
496
497    fn status_snapshot(&self) -> TreeStatusSnapshot {
498        TreeStatusSnapshot {
499            name: self.name().to_string(),
500            status: self.last_status,
501            active_capability: self.active_capability().map(String::from),
502            children: self.children.iter().map(|c| c.status_snapshot()).collect(),
503        }
504    }
505}
506
507/// Like `ParallelNode`, but with no memory of a prior resolution: every child is ticked, every
508/// call, unconditionally. Needed for an interrupt-condition layer that must be re-evaluated
509/// indefinitely (e.g. the operator clicking/clearing repeatedly over a session).
510#[derive(Debug)]
511pub struct WatchNode {
512    children: Vec<BoxedBehavior>,
513    policy: ParallelPolicy,
514    last_active: Option<usize>,
515    last_status: Option<NodeStatus>,
516}
517
518impl WatchNode {
519    /// Create a new watch node with the given children and policy.
520    pub fn new(children: Vec<BoxedBehavior>, policy: ParallelPolicy) -> Self {
521        Self {
522            children,
523            policy,
524            last_active: None,
525            last_status: None,
526        }
527    }
528
529    /// Add a child behavior to the watch node.
530    pub fn add_child(&mut self, child: BoxedBehavior) {
531        self.children.push(child);
532    }
533
534    /// Get the number of children.
535    pub fn len(&self) -> usize {
536        self.children.len()
537    }
538
539    /// Check if the watch node is empty.
540    pub fn is_empty(&self) -> bool {
541        self.children.is_empty()
542    }
543}
544
545#[async_trait]
546impl BehaviorNode for WatchNode {
547    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
548        if self.children.is_empty() {
549            self.last_active = None;
550            self.last_status = Some(NodeStatus::Failure);
551            return Ok(NodeStatus::Failure);
552        }
553
554        let mut statuses = Vec::with_capacity(self.children.len());
555        for child in &mut self.children {
556            statuses.push(child.tick(ctx).await?);
557        }
558
559        self.last_active = statuses.iter().position(|s| s.is_running());
560
561        let success_count = statuses.iter().filter(|s| s.is_success()).count();
562        let failure_count = statuses.iter().filter(|s| s.is_failure()).count();
563
564        let status = match self.policy {
565            ParallelPolicy::RequireAll => {
566                if failure_count > 0 {
567                    debug!("Watch (RequireAll): at least one child failed");
568                    NodeStatus::Failure
569                } else if success_count == self.children.len() {
570                    debug!("Watch (RequireAll): all children succeeded");
571                    NodeStatus::Success
572                } else {
573                    NodeStatus::Running
574                }
575            }
576            ParallelPolicy::RequireOne => {
577                if success_count > 0 {
578                    debug!("Watch (RequireOne): at least one child succeeded");
579                    NodeStatus::Success
580                } else if failure_count == self.children.len() {
581                    debug!("Watch (RequireOne): all children failed");
582                    NodeStatus::Failure
583                } else {
584                    NodeStatus::Running
585                }
586            }
587            ParallelPolicy::RequireN(n) => {
588                if success_count >= n {
589                    debug!("Watch (RequireN({})): {} children succeeded", n, success_count);
590                    NodeStatus::Success
591                } else if (self.children.len() - failure_count) < n {
592                    debug!("Watch (RequireN({})): not enough children can succeed", n);
593                    NodeStatus::Failure
594                } else {
595                    NodeStatus::Running
596                }
597            }
598        };
599
600        self.last_status = Some(status);
601        Ok(status)
602    }
603
604    fn name(&self) -> &str {
605        "watch"
606    }
607
608    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
609        crate::boxed_children_mut(self.children.iter_mut())
610    }
611
612    /// Unlike Sequence/Selector/Parallel, every child here is ticked every time with no persisted
613    /// statuses to read after the fact - `tick()` records which child resolved `Running` *this*
614    /// call into `last_active`, and this forwards to that child.
615    fn active_capability(&self) -> Option<&str> {
616        self.last_active
617            .and_then(|i| self.children.get(i))
618            .and_then(|child| child.active_capability())
619    }
620
621    fn status_snapshot(&self) -> TreeStatusSnapshot {
622        TreeStatusSnapshot {
623            name: self.name().to_string(),
624            status: self.last_status,
625            active_capability: self.active_capability().map(String::from),
626            children: self.children.iter().map(|c| c.status_snapshot()).collect(),
627        }
628    }
629}
630
631/// A single priority layer in a [`SubsumptionNode`]: a priority (higher wins) plus the behavior
632/// that implements it.
633#[derive(Debug)]
634pub struct SubsumptionLayer {
635    pub priority: u8,
636    pub behavior: BoxedBehavior,
637    pub name: Option<String>,
638}
639
640impl SubsumptionLayer {
641    mecha10_core::new_with_defaults!(
642        /// Create a new subsumption layer.
643        pub fn new(priority: u8, behavior: BoxedBehavior) -> Self {
644            name: None
645        }
646    );
647
648    mecha10_core::builder_setter!(into_some, with_name, name, impl Into<String>);
649}
650
651/// Priority-based preemption (subsumption architecture): on each tick, tries each layer
652/// highest-priority first. The first layer whose `tick()` returns `Running`/`Success` "wins";
653/// lower-priority layers aren't ticked that cycle, preserving their internal progress.
654#[derive(Debug)]
655pub struct SubsumptionNode {
656    pub layers: Vec<SubsumptionLayer>,
657    last_winning_layer: Option<usize>,
658    last_status: Option<NodeStatus>,
659}
660
661impl SubsumptionNode {
662    /// Create a new empty subsumption node.
663    pub fn new() -> Self {
664        Self {
665            layers: Vec::new(),
666            last_winning_layer: None,
667            last_status: None,
668        }
669    }
670
671    /// Add a layer with the given priority and behavior.
672    ///
673    /// Layers are automatically sorted by priority (highest first).
674    pub fn add_layer(mut self, priority: u8, behavior: BoxedBehavior) -> Self {
675        self.layers.push(SubsumptionLayer::new(priority, behavior));
676        self.layers.sort_by_key(|b| std::cmp::Reverse(b.priority));
677        self
678    }
679}
680
681impl Default for SubsumptionNode {
682    fn default() -> Self {
683        Self::new()
684    }
685}
686
687#[async_trait]
688impl BehaviorNode for SubsumptionNode {
689    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
690        if self.layers.is_empty() {
691            self.last_winning_layer = None;
692            self.last_status = Some(NodeStatus::Failure);
693            return Ok(NodeStatus::Failure);
694        }
695
696        for (i, layer) in self.layers.iter_mut().enumerate() {
697            let layer_name = layer.name.as_deref().unwrap_or("unnamed");
698
699            debug!(
700                "Subsumption: trying layer '{}' (priority {})",
701                layer_name, layer.priority
702            );
703
704            let status = layer.behavior.tick(ctx).await?;
705
706            match status {
707                NodeStatus::Success | NodeStatus::Running => {
708                    info!(
709                        "Subsumption: layer '{}' (priority {}) taking control with status {}",
710                        layer_name, layer.priority, status
711                    );
712                    self.last_winning_layer = Some(i);
713                    self.last_status = Some(status);
714                    return Ok(status);
715                }
716                NodeStatus::Failure => {
717                    debug!("Subsumption: layer '{}' failed, trying next layer", layer_name);
718                    continue;
719                }
720            }
721        }
722
723        debug!("Subsumption: all layers failed");
724        self.last_winning_layer = None;
725        self.last_status = Some(NodeStatus::Failure);
726        Ok(NodeStatus::Failure)
727    }
728
729    fn name(&self) -> &str {
730        "subsumption"
731    }
732
733    crate::children_mut_via_wrapped_behavior!(layers, layer);
734
735    /// Forwards the winning layer's capability (the highest-priority layer whose `tick()`
736    /// returned `Running`/`Success` this call) - `None` if every layer failed. Tracked separately
737    /// via `last_winning_layer` since it isn't otherwise recoverable after `tick()` returns.
738    fn active_capability(&self) -> Option<&str> {
739        self.last_winning_layer
740            .and_then(|i| self.layers.get(i))
741            .and_then(|layer| layer.behavior.active_capability())
742    }
743
744    fn status_snapshot(&self) -> TreeStatusSnapshot {
745        TreeStatusSnapshot {
746            name: self.name().to_string(),
747            status: self.last_status,
748            active_capability: self.active_capability().map(String::from),
749            children: self.layers.iter().map(|l| l.behavior.status_snapshot()).collect(),
750        }
751    }
752}
753
754/// Which [`ControllerMode`] variant a [`ConditionKind::ControllerModeIs`] predicate matches - the
755/// variant only, since `ControllerMode::Joints`'s joint-name list is irrelevant to the check.
756/// Deliberately mirrors [`ControllerMode`]'s own variant names (and
757/// [`mecha10_core::node::Capability::Locomotion`]/`Joints`) rather than inventing a parallel
758/// vocabulary.
759#[derive(Debug, Clone, Copy, PartialEq, Eq)]
760pub enum ControllerModeKind {
761    Locomotion,
762    Joints,
763}
764
765/// A predicate a [`ConditionNode`] evaluates against live project/robot state before deciding
766/// whether to tick its child. Deliberately a small, closed enum rather than a general expression
767/// language: add a variant here when a genuinely new predicate is needed. Starts with just the
768/// two controller-mode checks `patrol` needs today, but is reusable infrastructure - any behavior
769/// tree can gate a subtree on one of these without a bespoke composite node.
770#[derive(Debug, Clone, PartialEq, Eq)]
771pub enum ConditionKind {
772    /// True when the project's current [`mecha10_core::config::controller_mode`] is the given
773    /// variant - the same capability-based dispatch `wander`/`move` leaf nodes already use (see
774    /// `actions/movement.rs`'s `resolve_movement_output`), exposed as a composition-level gate so
775    /// an entire subtree can be made conditional on the project's actuation surface.
776    ControllerModeIs(ControllerModeKind),
777}
778
779impl ConditionKind {
780    /// Evaluate this predicate against the current process/project state.
781    fn evaluate(&self) -> bool {
782        match self {
783            ConditionKind::ControllerModeIs(kind) => {
784                let current = match controller_mode() {
785                    ControllerMode::Locomotion => ControllerModeKind::Locomotion,
786                    ControllerMode::Joints { .. } => ControllerModeKind::Joints,
787                };
788                current == *kind
789            }
790        }
791    }
792}
793
794/// Single-child decorator that gates its child on a [`ConditionKind`] predicate: it evaluates the
795/// predicate FIRST and only ticks the child (forwarding the child's status unchanged) while the
796/// predicate holds; when the predicate is false it reports `Failure` WITHOUT ticking the child at
797/// all.
798///
799/// Dropping several of these into a [`SelectOrNode`] gives capability- or state-gated branch
800/// selection out of a small, reusable building block: `SelectOrNode` already advances past any
801/// child that returns `Failure`, so a false condition naturally falls through to the next option
802/// without ever running its subtree. A smaller, more composable primitive than a bespoke
803/// branch-on-capability composite node, and it fits the existing decorator pattern
804/// ([`InverterNode`]/[`ForceSuccessNode`]/[`ForceFailureNode`]).
805#[derive(Debug)]
806pub struct ConditionNode {
807    condition: ConditionKind,
808    child: BoxedBehavior,
809    last_status: Option<NodeStatus>,
810}
811
812impl ConditionNode {
813    /// Create a new condition node gating `child` on `condition`.
814    pub fn new(condition: ConditionKind, child: BoxedBehavior) -> Self {
815        Self {
816            condition,
817            child,
818            last_status: None,
819        }
820    }
821}
822
823#[async_trait]
824impl BehaviorNode for ConditionNode {
825    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
826        let status = if self.condition.evaluate() {
827            self.child.tick(ctx).await?
828        } else {
829            debug!(
830                "Condition: predicate {:?} is false, returning Failure without ticking child",
831                self.condition
832            );
833            NodeStatus::Failure
834        };
835
836        self.last_status = Some(status);
837        Ok(status)
838    }
839
840    fn name(&self) -> &str {
841        "condition"
842    }
843
844    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
845        vec![self.child.as_mut()]
846    }
847
848    fn active_capability(&self) -> Option<&str> {
849        self.child.active_capability()
850    }
851
852    fn status_snapshot(&self) -> TreeStatusSnapshot {
853        TreeStatusSnapshot {
854            name: self.name().to_string(),
855            status: self.last_status,
856            active_capability: self.active_capability().map(String::from),
857            children: vec![self.child.status_snapshot()],
858        }
859    }
860}
861
862/// Negates a `Success`/`Failure` result from its single child; `Running` passes through
863/// unchanged. The canonical decorator for turning a "condition" leaf/subtree into its opposite
864/// (e.g. "target NOT in range") without duplicating the underlying check.
865#[derive(Debug)]
866pub struct InverterNode {
867    child: BoxedBehavior,
868    last_status: Option<NodeStatus>,
869}
870
871impl InverterNode {
872    /// Create a new inverter node wrapping `child`.
873    pub fn new(child: BoxedBehavior) -> Self {
874        Self {
875            child,
876            last_status: None,
877        }
878    }
879}
880
881#[async_trait]
882impl BehaviorNode for InverterNode {
883    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
884        let status = match self.child.tick(ctx).await? {
885            NodeStatus::Success => NodeStatus::Failure,
886            NodeStatus::Failure => NodeStatus::Success,
887            NodeStatus::Running => NodeStatus::Running,
888        };
889        self.last_status = Some(status);
890        Ok(status)
891    }
892
893    fn name(&self) -> &str {
894        "inverter"
895    }
896
897    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
898        vec![self.child.as_mut()]
899    }
900
901    fn active_capability(&self) -> Option<&str> {
902        self.child.active_capability()
903    }
904
905    fn status_snapshot(&self) -> TreeStatusSnapshot {
906        TreeStatusSnapshot {
907            name: self.name().to_string(),
908            status: self.last_status,
909            active_capability: self.active_capability().map(String::from),
910            children: vec![self.child.status_snapshot()],
911        }
912    }
913}
914
915/// Always reports `Success` once its child resolves, regardless of whether the child actually
916/// succeeded or failed; `Running` passes through unchanged. Useful for an optional step whose
917/// failure shouldn't abort the rest of a `Sequence`.
918#[derive(Debug)]
919pub struct ForceSuccessNode {
920    child: BoxedBehavior,
921    last_status: Option<NodeStatus>,
922}
923
924impl ForceSuccessNode {
925    /// Create a new force-success node wrapping `child`.
926    pub fn new(child: BoxedBehavior) -> Self {
927        Self {
928            child,
929            last_status: None,
930        }
931    }
932}
933
934#[async_trait]
935impl BehaviorNode for ForceSuccessNode {
936    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
937        let status = match self.child.tick(ctx).await? {
938            NodeStatus::Running => NodeStatus::Running,
939            NodeStatus::Success | NodeStatus::Failure => NodeStatus::Success,
940        };
941        self.last_status = Some(status);
942        Ok(status)
943    }
944
945    fn name(&self) -> &str {
946        "force_success"
947    }
948
949    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
950        vec![self.child.as_mut()]
951    }
952
953    fn active_capability(&self) -> Option<&str> {
954        self.child.active_capability()
955    }
956
957    fn status_snapshot(&self) -> TreeStatusSnapshot {
958        TreeStatusSnapshot {
959            name: self.name().to_string(),
960            status: self.last_status,
961            active_capability: self.active_capability().map(String::from),
962            children: vec![self.child.status_snapshot()],
963        }
964    }
965}
966
967/// Always reports `Failure` once its child resolves, regardless of whether the child actually
968/// succeeded or failed; `Running` passes through unchanged. The mirror of `ForceSuccessNode`,
969/// useful inside a `Selector` to force a fallback to the next option.
970#[derive(Debug)]
971pub struct ForceFailureNode {
972    child: BoxedBehavior,
973    last_status: Option<NodeStatus>,
974}
975
976impl ForceFailureNode {
977    /// Create a new force-failure node wrapping `child`.
978    pub fn new(child: BoxedBehavior) -> Self {
979        Self {
980            child,
981            last_status: None,
982        }
983    }
984}
985
986#[async_trait]
987impl BehaviorNode for ForceFailureNode {
988    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
989        let status = match self.child.tick(ctx).await? {
990            NodeStatus::Running => NodeStatus::Running,
991            NodeStatus::Success | NodeStatus::Failure => NodeStatus::Failure,
992        };
993        self.last_status = Some(status);
994        Ok(status)
995    }
996
997    fn name(&self) -> &str {
998        "force_failure"
999    }
1000
1001    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
1002        vec![self.child.as_mut()]
1003    }
1004
1005    fn active_capability(&self) -> Option<&str> {
1006        self.child.active_capability()
1007    }
1008
1009    fn status_snapshot(&self) -> TreeStatusSnapshot {
1010        TreeStatusSnapshot {
1011            name: self.name().to_string(),
1012            status: self.last_status,
1013            active_capability: self.active_capability().map(String::from),
1014            children: vec![self.child.status_snapshot()],
1015        }
1016    }
1017}
1018
1019/// Re-runs its child every time it succeeds, up to an optional fixed count; a `Failure` from the
1020/// child always ends the loop immediately with `Failure`. `count: None` repeats indefinitely
1021/// (e.g. a patrol leg re-run forever until something fails it).
1022#[derive(Debug)]
1023pub struct RepeatNode {
1024    child: BoxedBehavior,
1025    count: Option<u32>,
1026    completed: u32,
1027    last_status: Option<NodeStatus>,
1028}
1029
1030impl RepeatNode {
1031    /// Create a new repeat node wrapping `child`. `count: None` repeats indefinitely.
1032    pub fn new(child: BoxedBehavior, count: Option<u32>) -> Self {
1033        Self {
1034            child,
1035            count,
1036            completed: 0,
1037            last_status: None,
1038        }
1039    }
1040}
1041
1042#[async_trait]
1043impl BehaviorNode for RepeatNode {
1044    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
1045        if let Some(count) = self.count {
1046            if self.completed >= count {
1047                self.last_status = Some(NodeStatus::Success);
1048                return Ok(NodeStatus::Success);
1049            }
1050        }
1051
1052        let status = match self.child.tick(ctx).await? {
1053            NodeStatus::Success => {
1054                self.completed += 1;
1055                self.child.reset().await?;
1056
1057                match self.count {
1058                    Some(count) if self.completed >= count => NodeStatus::Success,
1059                    _ => NodeStatus::Running,
1060                }
1061            }
1062            NodeStatus::Failure => NodeStatus::Failure,
1063            NodeStatus::Running => NodeStatus::Running,
1064        };
1065
1066        self.last_status = Some(status);
1067        Ok(status)
1068    }
1069
1070    fn reset_self(&mut self) {
1071        self.completed = 0;
1072    }
1073
1074    fn name(&self) -> &str {
1075        "repeat"
1076    }
1077
1078    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
1079        vec![self.child.as_mut()]
1080    }
1081
1082    fn active_capability(&self) -> Option<&str> {
1083        self.child.active_capability()
1084    }
1085
1086    fn status_snapshot(&self) -> TreeStatusSnapshot {
1087        TreeStatusSnapshot {
1088            name: self.name().to_string(),
1089            status: self.last_status,
1090            active_capability: self.active_capability().map(String::from),
1091            children: vec![self.child.status_snapshot()],
1092        }
1093    }
1094}
1095
1096/// Re-runs its child up to `max_attempts` times whenever it fails, succeeding as soon as one
1097/// attempt succeeds; only reports `Failure` once every attempt has been exhausted. The
1098/// complement of `RepeatNode` (which loops on success).
1099#[derive(Debug)]
1100pub struct RetryNode {
1101    child: BoxedBehavior,
1102    max_attempts: u32,
1103    attempts: u32,
1104    last_status: Option<NodeStatus>,
1105}
1106
1107impl RetryNode {
1108    /// Create a new retry node wrapping `child`, allowing up to `max_attempts` tries (must be at
1109    /// least 1).
1110    pub fn new(child: BoxedBehavior, max_attempts: u32) -> Self {
1111        Self {
1112            child,
1113            max_attempts: max_attempts.max(1),
1114            attempts: 0,
1115            last_status: None,
1116        }
1117    }
1118}
1119
1120#[async_trait]
1121impl BehaviorNode for RetryNode {
1122    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
1123        let status = match self.child.tick(ctx).await? {
1124            NodeStatus::Success => NodeStatus::Success,
1125            NodeStatus::Running => NodeStatus::Running,
1126            NodeStatus::Failure => {
1127                self.attempts += 1;
1128                if self.attempts >= self.max_attempts {
1129                    debug!("Retry: child failed after {} attempt(s), giving up", self.attempts);
1130                    NodeStatus::Failure
1131                } else {
1132                    debug!(
1133                        "Retry: child failed on attempt {} of {}, retrying",
1134                        self.attempts, self.max_attempts
1135                    );
1136                    self.child.reset().await?;
1137                    NodeStatus::Running
1138                }
1139            }
1140        };
1141
1142        self.last_status = Some(status);
1143        Ok(status)
1144    }
1145
1146    fn reset_self(&mut self) {
1147        self.attempts = 0;
1148    }
1149
1150    fn name(&self) -> &str {
1151        "retry"
1152    }
1153
1154    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
1155        vec![self.child.as_mut()]
1156    }
1157
1158    fn active_capability(&self) -> Option<&str> {
1159        self.child.active_capability()
1160    }
1161
1162    fn status_snapshot(&self) -> TreeStatusSnapshot {
1163        TreeStatusSnapshot {
1164            name: self.name().to_string(),
1165            status: self.last_status,
1166            active_capability: self.active_capability().map(String::from),
1167            children: vec![self.child.status_snapshot()],
1168        }
1169    }
1170}
1171
1172/// Bounds how long its child may stay `Running`: once `duration` elapses without the child
1173/// resolving, immediately reports `Failure` (resetting the child's state). A child that resolves
1174/// before the deadline passes its status straight through.
1175#[derive(Debug)]
1176pub struct TimeoutNode {
1177    child: BoxedBehavior,
1178    duration: Duration,
1179    start_time: Option<Instant>,
1180    last_status: Option<NodeStatus>,
1181}
1182
1183impl TimeoutNode {
1184    /// Create a new timeout node wrapping `child`, bounding it to `duration`.
1185    pub fn new(child: BoxedBehavior, duration: Duration) -> Self {
1186        Self {
1187            child,
1188            duration,
1189            start_time: None,
1190            last_status: None,
1191        }
1192    }
1193}
1194
1195#[async_trait]
1196impl BehaviorNode for TimeoutNode {
1197    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
1198        let now = Instant::now();
1199        let start = *self.start_time.get_or_insert(now);
1200
1201        if now.duration_since(start) >= self.duration {
1202            warn!("Timeout: child did not resolve within {:?}, failing", self.duration);
1203            self.start_time = None;
1204            self.child.reset().await?;
1205            self.last_status = Some(NodeStatus::Failure);
1206            return Ok(NodeStatus::Failure);
1207        }
1208
1209        let status = self.child.tick(ctx).await?;
1210        if status.is_complete() {
1211            self.start_time = None;
1212        }
1213        self.last_status = Some(status);
1214        Ok(status)
1215    }
1216
1217    fn reset_self(&mut self) {
1218        self.start_time = None;
1219    }
1220
1221    fn name(&self) -> &str {
1222        "timeout"
1223    }
1224
1225    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
1226        vec![self.child.as_mut()]
1227    }
1228
1229    fn active_capability(&self) -> Option<&str> {
1230        self.child.active_capability()
1231    }
1232
1233    fn status_snapshot(&self) -> TreeStatusSnapshot {
1234        TreeStatusSnapshot {
1235            name: self.name().to_string(),
1236            status: self.last_status,
1237            active_capability: self.active_capability().map(String::from),
1238            children: vec![self.child.status_snapshot()],
1239        }
1240    }
1241}
1242
1243/// Bounds the GAP between runs of its child, the complement of [`TimeoutNode`] (which bounds how
1244/// long a single run may stay `Running`). Once the child returns a terminal result (`Success` or
1245/// `Failure`), this node refuses to re-tick it for `cooldown`: during that window `tick()`
1246/// returns `Failure` immediately WITHOUT ticking the child at all. Once the window elapses,
1247/// ticking resumes normally. A child that returns `Running` doesn't start the cooldown clock -
1248/// only a terminal result does.
1249#[derive(Debug)]
1250pub struct CooldownNode {
1251    child: BoxedBehavior,
1252    cooldown: Duration,
1253    /// Set to the instant the cooldown window ends whenever the child resolves; `None` means no
1254    /// cooldown is in effect (either the child has never resolved, or the window already elapsed
1255    /// and was cleared).
1256    cooldown_until: Option<Instant>,
1257    last_status: Option<NodeStatus>,
1258}
1259
1260impl CooldownNode {
1261    /// Create a new cooldown node wrapping `child`, refusing to re-tick it for `cooldown` after
1262    /// it last resolved.
1263    pub fn new(child: BoxedBehavior, cooldown: Duration) -> Self {
1264        Self {
1265            child,
1266            cooldown,
1267            cooldown_until: None,
1268            last_status: None,
1269        }
1270    }
1271}
1272
1273#[async_trait]
1274impl BehaviorNode for CooldownNode {
1275    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
1276        if let Some(until) = self.cooldown_until {
1277            if Instant::now() < until {
1278                debug!("Cooldown: still cooling down, returning Failure without ticking child");
1279                self.last_status = Some(NodeStatus::Failure);
1280                return Ok(NodeStatus::Failure);
1281            }
1282            self.cooldown_until = None;
1283        }
1284
1285        let status = self.child.tick(ctx).await?;
1286        if status.is_complete() {
1287            self.cooldown_until = Some(Instant::now() + self.cooldown);
1288        }
1289        self.last_status = Some(status);
1290        Ok(status)
1291    }
1292
1293    fn reset_self(&mut self) {
1294        self.cooldown_until = None;
1295    }
1296
1297    fn name(&self) -> &str {
1298        "cooldown"
1299    }
1300
1301    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
1302        vec![self.child.as_mut()]
1303    }
1304
1305    fn active_capability(&self) -> Option<&str> {
1306        self.child.active_capability()
1307    }
1308
1309    fn status_snapshot(&self) -> TreeStatusSnapshot {
1310        TreeStatusSnapshot {
1311            name: self.name().to_string(),
1312            status: self.last_status,
1313            active_capability: self.active_capability().map(String::from),
1314            children: vec![self.child.status_snapshot()],
1315        }
1316    }
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321    use super::*;
1322
1323    #[derive(Debug, Default)]
1324    struct DummyBehavior;
1325
1326    #[async_trait]
1327    impl BehaviorNode for DummyBehavior {
1328        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1329            unimplemented!("not exercised by these tests")
1330        }
1331    }
1332
1333    #[derive(Debug)]
1334    struct CapabilityBehavior(&'static str);
1335
1336    #[async_trait]
1337    impl BehaviorNode for CapabilityBehavior {
1338        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1339            unimplemented!("not exercised by these tests")
1340        }
1341
1342        fn active_capability(&self) -> Option<&str> {
1343            Some(self.0)
1344        }
1345    }
1346
1347    #[test]
1348    fn sequence_children_mut_exposes_every_child() {
1349        let mut node = SequenceNode::new(vec![Box::new(DummyBehavior), Box::new(DummyBehavior)]);
1350        assert_eq!(node.children_mut().len(), 2);
1351    }
1352
1353    #[tokio::test]
1354    async fn sequence_reset_rewinds_current_index() {
1355        let mut node = SequenceNode::new(vec![Box::new(DummyBehavior)]);
1356        node.current_index = 1;
1357
1358        node.reset().await.unwrap();
1359
1360        assert_eq!(node.current_index, 0);
1361    }
1362
1363    #[test]
1364    fn selector_children_mut_exposes_every_child() {
1365        let mut node = SelectOrNode::new(vec![Box::new(DummyBehavior), Box::new(DummyBehavior)]);
1366        assert_eq!(node.children_mut().len(), 2);
1367    }
1368
1369    #[tokio::test]
1370    async fn selector_reset_rewinds_current_index() {
1371        let mut node = SelectOrNode::new(vec![Box::new(DummyBehavior)]);
1372        node.current_index = 1;
1373
1374        node.reset().await.unwrap();
1375
1376        assert_eq!(node.current_index, 0);
1377    }
1378
1379    #[test]
1380    fn parallel_children_mut_exposes_every_child() {
1381        let mut node = ParallelNode::new(
1382            vec![Box::new(DummyBehavior), Box::new(DummyBehavior)],
1383            ParallelPolicy::RequireAll,
1384        );
1385        assert_eq!(node.children_mut().len(), 2);
1386    }
1387
1388    #[tokio::test]
1389    async fn parallel_reset_clears_child_statuses_back_to_running() {
1390        let mut node = ParallelNode::new(vec![Box::new(DummyBehavior)], ParallelPolicy::RequireAll);
1391        node.child_statuses[0] = NodeStatus::Success;
1392
1393        node.reset().await.unwrap();
1394
1395        assert_eq!(node.child_statuses[0], NodeStatus::Running);
1396    }
1397
1398    #[test]
1399    fn sequence_forwards_the_current_childs_active_capability() {
1400        let node = SequenceNode::new(vec![Box::new(CapabilityBehavior("rl")), Box::new(DummyBehavior)]);
1401        assert_eq!(node.active_capability(), Some("rl"));
1402    }
1403
1404    #[test]
1405    fn sequence_has_no_active_capability_once_past_the_declaring_child() {
1406        let mut node = SequenceNode::new(vec![Box::new(CapabilityBehavior("rl")), Box::new(DummyBehavior)]);
1407        node.current_index = 1;
1408        assert_eq!(node.active_capability(), None);
1409    }
1410
1411    #[test]
1412    fn sequence_status_snapshot_is_none_before_any_tick() {
1413        let node = SequenceNode::new(vec![Box::new(DummyBehavior)]);
1414        let snapshot = node.status_snapshot();
1415        assert_eq!(snapshot.status, None);
1416        assert_eq!(snapshot.name, "sequence");
1417        assert_eq!(
1418            snapshot.children.len(),
1419            1,
1420            "children must still be recursed into pre-tick"
1421        );
1422    }
1423
1424    /// The core `status_snapshot()` contract for a composite node: the reported `status` must
1425    /// match exactly what `tick()` itself returned, and `children` must recursively include each
1426    /// child's own snapshot (here, `StatusAndCapabilityBehavior` - defined below for the
1427    /// `SubsumptionNode` tests - doubling as a simple fixed-status child).
1428    #[tokio::test]
1429    #[ignore = "Requires Redis infrastructure"]
1430    async fn sequence_status_snapshot_matches_the_last_tick_result_and_recurses_into_children() {
1431        let ctx = crate::test_helpers::create_test_context("sequence_status_snapshot").await;
1432        let mut node = SequenceNode::new(vec![Box::new(StatusAndCapabilityBehavior {
1433            status: NodeStatus::Success,
1434            capability: "rl",
1435        })]);
1436
1437        let status = node.tick(&ctx).await.unwrap();
1438        assert_eq!(status, NodeStatus::Success);
1439
1440        let snapshot = node.status_snapshot();
1441        assert_eq!(snapshot.status, Some(status));
1442        assert_eq!(snapshot.name, "sequence");
1443        assert_eq!(
1444            snapshot.active_capability, None,
1445            "the only child already succeeded and was advanced past, so no capability is active"
1446        );
1447        assert_eq!(snapshot.children.len(), 1);
1448    }
1449
1450    #[test]
1451    fn selector_forwards_the_current_childs_active_capability() {
1452        let node = SelectOrNode::new(vec![Box::new(CapabilityBehavior("rl")), Box::new(DummyBehavior)]);
1453        assert_eq!(node.active_capability(), Some("rl"));
1454    }
1455
1456    #[test]
1457    fn parallel_forwards_the_first_running_childs_active_capability() {
1458        let mut node = ParallelNode::new(
1459            vec![Box::new(CapabilityBehavior("rl")), Box::new(DummyBehavior)],
1460            ParallelPolicy::RequireAll,
1461        );
1462        node.child_statuses[0] = NodeStatus::Success;
1463        assert_eq!(
1464            node.active_capability(),
1465            None,
1466            "a non-running child's capability must not be reported"
1467        );
1468    }
1469
1470    /// A child whose status changes from tick to tick (e.g. a target that comes and goes), used
1471    /// to prove `WatchNode` re-evaluates every call instead of freezing like `ParallelNode`.
1472    #[derive(Debug)]
1473    struct SequencedStatusBehavior {
1474        statuses: Vec<NodeStatus>,
1475        index: usize,
1476        tick_count: usize,
1477        capability: Option<&'static str>,
1478    }
1479
1480    impl SequencedStatusBehavior {
1481        fn new(statuses: Vec<NodeStatus>) -> Self {
1482            Self {
1483                statuses,
1484                index: 0,
1485                tick_count: 0,
1486                capability: None,
1487            }
1488        }
1489
1490        fn with_capability(statuses: Vec<NodeStatus>, capability: &'static str) -> Self {
1491            Self {
1492                capability: Some(capability),
1493                ..Self::new(statuses)
1494            }
1495        }
1496    }
1497
1498    #[async_trait]
1499    impl BehaviorNode for SequencedStatusBehavior {
1500        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1501            self.tick_count += 1;
1502            let status = self.statuses[self.index.min(self.statuses.len() - 1)];
1503            if self.index + 1 < self.statuses.len() {
1504                self.index += 1;
1505            }
1506            Ok(status)
1507        }
1508
1509        fn active_capability(&self) -> Option<&str> {
1510            self.capability
1511        }
1512    }
1513
1514    #[test]
1515    fn watch_children_mut_exposes_every_child() {
1516        let mut node = WatchNode::new(
1517            vec![Box::new(DummyBehavior), Box::new(DummyBehavior)],
1518            ParallelPolicy::RequireOne,
1519        );
1520        assert_eq!(node.children_mut().len(), 2);
1521    }
1522
1523    #[tokio::test]
1524    #[ignore = "Requires Redis infrastructure"]
1525    async fn watch_is_empty_returns_failure() {
1526        let ctx = crate::test_helpers::create_test_context("watch_empty").await;
1527        let mut node = WatchNode::new(vec![], ParallelPolicy::RequireOne);
1528        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1529    }
1530
1531    /// Regression test: unlike `ParallelNode`, `WatchNode` has no `child_statuses` memory, so a
1532    /// child that flips from `Failure` to `Running` across ticks must be re-ticked every call and
1533    /// the aggregate status must reflect *that* tick's actual child statuses, not a stale one.
1534    #[tokio::test]
1535    #[ignore = "Requires Redis infrastructure"]
1536    async fn watch_re_ticks_every_child_every_call_and_reflects_the_latest_statuses() {
1537        let ctx = crate::test_helpers::create_test_context("watch_re_ticks").await;
1538        let mut node = WatchNode::new(
1539            vec![Box::new(SequencedStatusBehavior::new(vec![
1540                NodeStatus::Failure,
1541                NodeStatus::Running,
1542                NodeStatus::Failure,
1543            ]))],
1544            ParallelPolicy::RequireOne,
1545        );
1546
1547        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1548        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1549        assert_eq!(
1550            node.tick(&ctx).await.unwrap(),
1551            NodeStatus::Failure,
1552            "a ParallelNode would have frozen on the first Failure and never re-ticked the child"
1553        );
1554    }
1555
1556    #[tokio::test]
1557    #[ignore = "Requires Redis infrastructure"]
1558    async fn watch_forwards_the_currently_running_childs_active_capability() {
1559        let ctx = crate::test_helpers::create_test_context("watch_capability").await;
1560        let mut node = WatchNode::new(
1561            vec![Box::new(SequencedStatusBehavior::with_capability(
1562                vec![NodeStatus::Running, NodeStatus::Failure],
1563                "instance_track",
1564            ))],
1565            ParallelPolicy::RequireOne,
1566        );
1567
1568        node.tick(&ctx).await.unwrap();
1569        assert_eq!(node.active_capability(), Some("instance_track"));
1570
1571        node.tick(&ctx).await.unwrap();
1572        assert_eq!(
1573            node.active_capability(),
1574            None,
1575            "a child that was running last tick and is not running this tick must not keep reporting its capability"
1576        );
1577    }
1578
1579    #[test]
1580    fn subsumption_children_mut_exposes_every_layer_behavior() {
1581        let mut node = SubsumptionNode::new()
1582            .add_layer(10, Box::new(DummyBehavior))
1583            .add_layer(5, Box::new(DummyBehavior));
1584        assert_eq!(node.children_mut().len(), 2);
1585    }
1586
1587    /// A double that both returns a fixed `NodeStatus` from `tick()` and declares a capability -
1588    /// `CapabilityBehavior` above only does the latter, so it can't exercise
1589    /// `SubsumptionNode::active_capability()` end-to-end through an actual `tick()` call.
1590    #[derive(Debug)]
1591    struct StatusAndCapabilityBehavior {
1592        status: NodeStatus,
1593        capability: &'static str,
1594    }
1595
1596    #[async_trait]
1597    impl BehaviorNode for StatusAndCapabilityBehavior {
1598        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1599            Ok(self.status)
1600        }
1601
1602        fn active_capability(&self) -> Option<&str> {
1603            Some(self.capability)
1604        }
1605    }
1606
1607    #[tokio::test]
1608    #[ignore = "Requires Redis infrastructure"]
1609    async fn subsumption_active_capability_forwards_the_winning_layers_capability() {
1610        let ctx = crate::test_helpers::create_test_context("subsumption_capability").await;
1611        let mut node = SubsumptionNode::new()
1612            .add_layer(
1613                10,
1614                Box::new(StatusAndCapabilityBehavior {
1615                    status: NodeStatus::Failure,
1616                    capability: "instance_track",
1617                }),
1618            )
1619            .add_layer(
1620                0,
1621                Box::new(StatusAndCapabilityBehavior {
1622                    status: NodeStatus::Running,
1623                    capability: "patrol",
1624                }),
1625            );
1626
1627        assert_eq!(node.active_capability(), None, "no tick has happened yet");
1628
1629        let status = node.tick(&ctx).await.unwrap();
1630        assert_eq!(
1631            status,
1632            NodeStatus::Running,
1633            "the high-priority layer fails, so the low layer wins"
1634        );
1635        assert_eq!(
1636            node.active_capability(),
1637            Some("patrol"),
1638            "active_capability must forward the winning (low-priority) layer's capability, not the failed high-priority one"
1639        );
1640    }
1641
1642    #[tokio::test]
1643    #[ignore = "Requires Redis infrastructure"]
1644    async fn subsumption_active_capability_none_when_all_layers_fail() {
1645        let ctx = crate::test_helpers::create_test_context("subsumption_capability_all_fail").await;
1646        let mut node =
1647            SubsumptionNode::new().add_layer(10, Box::new(SequencedStatusBehavior::new(vec![NodeStatus::Failure])));
1648
1649        node.tick(&ctx).await.unwrap();
1650        assert_eq!(node.active_capability(), None);
1651    }
1652
1653    /// A stub mimicking `CapabilityTaskNode`/`SetCapabilityTargetNode`'s (instance-track
1654    /// configuration) Failure-when-idle/Running-when-engaged convergence, controllable via a
1655    /// shared flag - zero `Context`/messaging dependency, same style as `DummyBehavior` above.
1656    #[derive(Debug)]
1657    struct ToggleableBehavior {
1658        engaged: std::sync::Arc<std::sync::atomic::AtomicBool>,
1659    }
1660
1661    #[async_trait]
1662    impl BehaviorNode for ToggleableBehavior {
1663        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1664            Ok(if self.engaged.load(std::sync::atomic::Ordering::SeqCst) {
1665                NodeStatus::Running
1666            } else {
1667                NodeStatus::Failure
1668            })
1669        }
1670    }
1671
1672    /// Simulates one leg of `patrol`'s timed-move sequence: `Running` for a fixed number
1673    /// of ticks, then `Success`. Records how many times it was ticked, so the test can observe
1674    /// whether a frozen `SequenceNode` child is skipped while a higher-priority layer is engaged.
1675    #[derive(Debug)]
1676    struct CountedLegBehavior {
1677        running_ticks_remaining: usize,
1678        tick_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1679    }
1680
1681    #[async_trait]
1682    impl BehaviorNode for CountedLegBehavior {
1683        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1684            self.tick_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1685            if self.running_ticks_remaining == 0 {
1686                Ok(NodeStatus::Success)
1687            } else {
1688                self.running_ticks_remaining -= 1;
1689                Ok(NodeStatus::Running)
1690            }
1691        }
1692    }
1693
1694    /// The property that matters most: a `Subsumption` tree with a `Watch`-wrapped interrupt layer
1695    /// must let the interrupt freeze the main layer mid-sequence, then hand control back on the
1696    /// *same* tick it disengages, resuming exactly where it was.
1697    #[tokio::test]
1698    #[ignore = "Requires Redis infrastructure"]
1699    async fn subsumption_preserves_lower_layer_progress_across_an_interrupt() {
1700        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1701        use std::sync::Arc;
1702
1703        let ctx = crate::test_helpers::create_test_context("subsumption_resume").await;
1704
1705        let engaged = Arc::new(AtomicBool::new(false));
1706        let leg1_ticks = Arc::new(AtomicUsize::new(0));
1707        let leg2_ticks = Arc::new(AtomicUsize::new(0));
1708
1709        let watch = WatchNode::new(
1710            vec![Box::new(ToggleableBehavior {
1711                engaged: engaged.clone(),
1712            })],
1713            ParallelPolicy::RequireOne,
1714        );
1715
1716        let sequence = SequenceNode::new(vec![
1717            Box::new(CountedLegBehavior {
1718                running_ticks_remaining: 2,
1719                tick_count: leg1_ticks.clone(),
1720            }),
1721            Box::new(CountedLegBehavior {
1722                running_ticks_remaining: 2,
1723                tick_count: leg2_ticks.clone(),
1724            }),
1725        ]);
1726
1727        let mut subsumption = SubsumptionNode::new()
1728            .add_layer(100, Box::new(watch))
1729            .add_layer(0, Box::new(sequence));
1730
1731        assert_eq!(subsumption.tick(&ctx).await.unwrap(), NodeStatus::Running);
1732        assert_eq!(leg1_ticks.load(Ordering::SeqCst), 1);
1733        assert_eq!(subsumption.tick(&ctx).await.unwrap(), NodeStatus::Running);
1734        assert_eq!(leg1_ticks.load(Ordering::SeqCst), 2);
1735
1736        assert_eq!(subsumption.tick(&ctx).await.unwrap(), NodeStatus::Running);
1737        assert_eq!(leg1_ticks.load(Ordering::SeqCst), 3);
1738        assert_eq!(leg2_ticks.load(Ordering::SeqCst), 1);
1739
1740        engaged.store(true, Ordering::SeqCst);
1741        for _ in 0..3 {
1742            assert_eq!(
1743                subsumption.tick(&ctx).await.unwrap(),
1744                NodeStatus::Running,
1745                "the instance_track layer wins while engaged"
1746            );
1747        }
1748        assert_eq!(
1749            leg2_ticks.load(Ordering::SeqCst),
1750            1,
1751            "the main layer must be frozen (not reset, not advanced) while the interrupt layer is engaged"
1752        );
1753
1754        engaged.store(false, Ordering::SeqCst);
1755        let status = subsumption.tick(&ctx).await.unwrap();
1756        assert_eq!(
1757            status,
1758            NodeStatus::Running,
1759            "handback must surface the Sequence's own status, not Success/Failure from the ceding interrupt layer"
1760        );
1761        assert_eq!(
1762            leg1_ticks.load(Ordering::SeqCst),
1763            3,
1764            "leg1 must not be re-ticked - the sequence was not reset"
1765        );
1766        assert_eq!(
1767            leg2_ticks.load(Ordering::SeqCst),
1768            2,
1769            "the Sequence resumes ticking leg2 (not leg1) - progress was preserved, not reset"
1770        );
1771    }
1772
1773    /// The full 3-layer safety stack `find_target`/`patrol` wire up: a priority-255
1774    /// `emergency-stop-interrupt` layer above a priority-100 `instance-track-interrupt` layer
1775    /// above the priority-0 main layer. Proves the e-stop layer wins over BOTH lower layers
1776    /// (even while instance_track is itself engaged), freezes the main layer's progress while
1777    /// active, and on clearing hands control back exactly where it left off - the same
1778    /// resume-exactly-where-it-was property `subsumption_preserves_lower_layer_progress_across_an_interrupt`
1779    /// proves for a single interrupt layer, extended to prove the higher-priority layer also
1780    /// preempts an already-engaged lower-priority interrupt layer, not just the main layer.
1781    #[tokio::test]
1782    #[ignore = "Requires Redis infrastructure"]
1783    async fn emergency_stop_layer_preempts_both_instance_track_and_the_main_layer_and_resumes_on_clear() {
1784        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1785        use std::sync::Arc;
1786
1787        let ctx = crate::test_helpers::create_test_context("subsumption_estop_resume").await;
1788
1789        let estop_active = Arc::new(AtomicBool::new(false));
1790        let instance_track_engaged = Arc::new(AtomicBool::new(false));
1791        let leg1_ticks = Arc::new(AtomicUsize::new(0));
1792        let leg2_ticks = Arc::new(AtomicUsize::new(0));
1793
1794        let estop_watch = WatchNode::new(
1795            vec![Box::new(ToggleableBehavior {
1796                engaged: estop_active.clone(),
1797            })],
1798            ParallelPolicy::RequireOne,
1799        );
1800
1801        let instance_track_watch = WatchNode::new(
1802            vec![Box::new(ToggleableBehavior {
1803                engaged: instance_track_engaged.clone(),
1804            })],
1805            ParallelPolicy::RequireOne,
1806        );
1807
1808        let main_sequence = SequenceNode::new(vec![
1809            Box::new(CountedLegBehavior {
1810                running_ticks_remaining: 2,
1811                tick_count: leg1_ticks.clone(),
1812            }),
1813            Box::new(CountedLegBehavior {
1814                running_ticks_remaining: 2,
1815                tick_count: leg2_ticks.clone(),
1816            }),
1817        ]);
1818
1819        let mut subsumption = SubsumptionNode::new()
1820            .add_layer(255, Box::new(estop_watch))
1821            .add_layer(100, Box::new(instance_track_watch))
1822            .add_layer(0, Box::new(main_sequence));
1823
1824        // Main layer makes progress while nothing is interrupting.
1825        assert_eq!(subsumption.tick(&ctx).await.unwrap(), NodeStatus::Running);
1826        assert_eq!(leg1_ticks.load(Ordering::SeqCst), 1);
1827
1828        // instance_track engages and wins over the main layer, as in the 2-layer case.
1829        instance_track_engaged.store(true, Ordering::SeqCst);
1830        assert_eq!(subsumption.tick(&ctx).await.unwrap(), NodeStatus::Running);
1831        assert_eq!(
1832            leg1_ticks.load(Ordering::SeqCst),
1833            1,
1834            "main layer frozen while instance_track is engaged"
1835        );
1836
1837        // Now e-stop fires WHILE instance_track is still engaged - priority 255 must win over
1838        // BOTH lower layers, not just the main one.
1839        estop_active.store(true, Ordering::SeqCst);
1840        for _ in 0..3 {
1841            assert_eq!(
1842                subsumption.tick(&ctx).await.unwrap(),
1843                NodeStatus::Running,
1844                "the emergency-stop layer must win even over an already-engaged instance_track layer"
1845            );
1846        }
1847        assert_eq!(
1848            leg1_ticks.load(Ordering::SeqCst),
1849            1,
1850            "main layer must stay frozen while e-stop is active"
1851        );
1852
1853        // instance_track clears while e-stop is still active - e-stop must keep winning.
1854        instance_track_engaged.store(false, Ordering::SeqCst);
1855        assert_eq!(
1856            subsumption.tick(&ctx).await.unwrap(),
1857            NodeStatus::Running,
1858            "e-stop still active, so it keeps control even after instance_track clears"
1859        );
1860        assert_eq!(leg1_ticks.load(Ordering::SeqCst), 1, "main layer still frozen");
1861
1862        // e-stop clears - control hands back to the main layer, resuming (not restarting) leg1.
1863        estop_active.store(false, Ordering::SeqCst);
1864        let status = subsumption.tick(&ctx).await.unwrap();
1865        assert_eq!(
1866            status,
1867            NodeStatus::Running,
1868            "handback surfaces the Sequence's own status once e-stop clears"
1869        );
1870        assert_eq!(
1871            leg1_ticks.load(Ordering::SeqCst),
1872            2,
1873            "leg1 resumes from where it left off (1 prior tick), not reset to 0"
1874        );
1875        assert_eq!(leg2_ticks.load(Ordering::SeqCst), 0, "leg2 not yet reached");
1876    }
1877
1878    #[derive(Debug)]
1879    struct FixedStatusBehavior(NodeStatus);
1880
1881    #[async_trait]
1882    impl BehaviorNode for FixedStatusBehavior {
1883        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1884            Ok(self.0)
1885        }
1886    }
1887
1888    #[tokio::test]
1889    #[ignore = "Requires Redis infrastructure"]
1890    async fn inverter_flips_success_and_failure_but_passes_running_through() {
1891        let ctx = crate::test_helpers::create_test_context("inverter").await;
1892
1893        let mut success = InverterNode::new(Box::new(FixedStatusBehavior(NodeStatus::Success)));
1894        assert_eq!(success.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1895
1896        let mut failure = InverterNode::new(Box::new(FixedStatusBehavior(NodeStatus::Failure)));
1897        assert_eq!(failure.tick(&ctx).await.unwrap(), NodeStatus::Success);
1898
1899        let mut running = InverterNode::new(Box::new(FixedStatusBehavior(NodeStatus::Running)));
1900        assert_eq!(running.tick(&ctx).await.unwrap(), NodeStatus::Running);
1901    }
1902
1903    #[tokio::test]
1904    #[ignore = "Requires Redis infrastructure"]
1905    async fn force_success_reports_success_even_when_child_fails() {
1906        let ctx = crate::test_helpers::create_test_context("force_success").await;
1907
1908        let mut node = ForceSuccessNode::new(Box::new(FixedStatusBehavior(NodeStatus::Failure)));
1909        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
1910
1911        let mut running = ForceSuccessNode::new(Box::new(FixedStatusBehavior(NodeStatus::Running)));
1912        assert_eq!(running.tick(&ctx).await.unwrap(), NodeStatus::Running);
1913    }
1914
1915    #[tokio::test]
1916    #[ignore = "Requires Redis infrastructure"]
1917    async fn force_failure_reports_failure_even_when_child_succeeds() {
1918        let ctx = crate::test_helpers::create_test_context("force_failure").await;
1919
1920        let mut node = ForceFailureNode::new(Box::new(FixedStatusBehavior(NodeStatus::Success)));
1921        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1922
1923        let mut running = ForceFailureNode::new(Box::new(FixedStatusBehavior(NodeStatus::Running)));
1924        assert_eq!(running.tick(&ctx).await.unwrap(), NodeStatus::Running);
1925    }
1926
1927    #[tokio::test]
1928    #[ignore = "Requires Redis infrastructure"]
1929    async fn repeat_with_count_succeeds_after_n_successful_runs() {
1930        let ctx = crate::test_helpers::create_test_context("repeat_count").await;
1931
1932        let mut node = RepeatNode::new(Box::new(FixedStatusBehavior(NodeStatus::Success)), Some(3));
1933        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1934        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1935        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
1936    }
1937
1938    #[tokio::test]
1939    #[ignore = "Requires Redis infrastructure"]
1940    async fn repeat_stops_immediately_on_child_failure() {
1941        let ctx = crate::test_helpers::create_test_context("repeat_failure").await;
1942
1943        let mut node = RepeatNode::new(Box::new(FixedStatusBehavior(NodeStatus::Failure)), None);
1944        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1945    }
1946
1947    #[tokio::test]
1948    #[ignore = "Requires Redis infrastructure"]
1949    async fn retry_succeeds_once_an_attempt_within_the_budget_succeeds() {
1950        let ctx = crate::test_helpers::create_test_context("retry_succeeds").await;
1951
1952        let mut node = RetryNode::new(
1953            Box::new(SequencedStatusBehavior::new(vec![
1954                NodeStatus::Failure,
1955                NodeStatus::Failure,
1956                NodeStatus::Success,
1957            ])),
1958            3,
1959        );
1960        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1961        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1962        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
1963    }
1964
1965    #[tokio::test]
1966    #[ignore = "Requires Redis infrastructure"]
1967    async fn retry_fails_once_every_attempt_is_exhausted() {
1968        let ctx = crate::test_helpers::create_test_context("retry_exhausted").await;
1969
1970        let mut node = RetryNode::new(Box::new(FixedStatusBehavior(NodeStatus::Failure)), 2);
1971        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1972        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1973    }
1974
1975    #[tokio::test]
1976    #[ignore = "Requires Redis infrastructure"]
1977    async fn timeout_passes_through_a_child_that_resolves_before_the_deadline() {
1978        let ctx = crate::test_helpers::create_test_context("timeout_resolves").await;
1979
1980        let mut node = TimeoutNode::new(
1981            Box::new(FixedStatusBehavior(NodeStatus::Success)),
1982            Duration::from_secs(60),
1983        );
1984        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
1985    }
1986
1987    #[tokio::test]
1988    #[ignore = "Requires Redis infrastructure"]
1989    async fn timeout_fails_once_the_child_has_been_running_too_long() {
1990        let ctx = crate::test_helpers::create_test_context("timeout_expires").await;
1991
1992        let mut node = TimeoutNode::new(Box::new(FixedStatusBehavior(NodeStatus::Running)), Duration::ZERO);
1993        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1994    }
1995
1996    // `controller_mode()` reads process-global env vars; serialize tests that touch them so they
1997    // can't observe each other's in-flight state - mirrors `actions/movement.rs`'s own
1998    // `CONTROLLER_ENV_LOCK` convention (a separate lock, since statics aren't shared across
1999    // modules, but guarding the same underlying env vars).
2000    //
2001    // This needs to be a `tokio::sync::Mutex`, not `std::sync::Mutex`: the async tests below
2002    // hold the guard across `.await` points (by design - other tests' env var mutations must
2003    // stay locked out for the whole test body, not just up to the first await), and holding a
2004    // non-async-aware guard across an await is a hard compile error (`clippy::await_holding_lock`
2005    // promoted to deny under this workspace's lint config).
2006    static CONTROLLER_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
2007
2008    fn clear_controller_env() {
2009        std::env::remove_var(mecha10_core::config::CAPABILITIES_ENV_VAR);
2010        std::env::remove_var(mecha10_core::config::ACTUATION_JOINT_NAMES_ENV_VAR);
2011    }
2012
2013    #[test]
2014    fn controller_mode_is_evaluates_against_the_current_controller_mode() {
2015        let _guard = CONTROLLER_ENV_LOCK.blocking_lock();
2016        clear_controller_env();
2017
2018        // No capability env set => the project falls back to Locomotion (see
2019        // `mecha10_core::config::controller_mode`).
2020        assert!(ConditionKind::ControllerModeIs(ControllerModeKind::Locomotion).evaluate());
2021        assert!(!ConditionKind::ControllerModeIs(ControllerModeKind::Joints).evaluate());
2022
2023        std::env::set_var(mecha10_core::config::CAPABILITIES_ENV_VAR, r#"["joints"]"#);
2024        assert!(ConditionKind::ControllerModeIs(ControllerModeKind::Joints).evaluate());
2025        assert!(!ConditionKind::ControllerModeIs(ControllerModeKind::Locomotion).evaluate());
2026
2027        clear_controller_env();
2028    }
2029
2030    #[tokio::test]
2031    #[ignore = "Requires Redis infrastructure"]
2032    async fn condition_ticks_child_and_forwards_its_status_when_the_predicate_holds() {
2033        let _guard = CONTROLLER_ENV_LOCK.lock().await;
2034        clear_controller_env(); // => Locomotion
2035
2036        let ctx = crate::test_helpers::create_test_context("condition_true").await;
2037        let mut node = ConditionNode::new(
2038            ConditionKind::ControllerModeIs(ControllerModeKind::Locomotion),
2039            Box::new(FixedStatusBehavior(NodeStatus::Running)),
2040        );
2041
2042        assert_eq!(
2043            node.tick(&ctx).await.unwrap(),
2044            NodeStatus::Running,
2045            "a true predicate must tick the child and forward its status unchanged"
2046        );
2047
2048        clear_controller_env();
2049    }
2050
2051    #[tokio::test]
2052    #[ignore = "Requires Redis infrastructure"]
2053    async fn condition_returns_failure_without_ticking_child_when_the_predicate_is_false() {
2054        let _guard = CONTROLLER_ENV_LOCK.lock().await;
2055        clear_controller_env();
2056        std::env::set_var(mecha10_core::config::CAPABILITIES_ENV_VAR, r#"["joints"]"#);
2057
2058        let ctx = crate::test_helpers::create_test_context("condition_false").await;
2059        // `DummyBehavior::tick` is `unimplemented!()`, so the test would panic if the child were
2060        // ticked - proving a false predicate short-circuits to Failure without touching it.
2061        let mut node = ConditionNode::new(
2062            ConditionKind::ControllerModeIs(ControllerModeKind::Locomotion),
2063            Box::new(DummyBehavior),
2064        );
2065
2066        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
2067
2068        clear_controller_env();
2069    }
2070
2071    #[test]
2072    fn condition_children_mut_exposes_the_child() {
2073        let mut node = ConditionNode::new(
2074            ConditionKind::ControllerModeIs(ControllerModeKind::Joints),
2075            Box::new(DummyBehavior),
2076        );
2077        assert_eq!(node.children_mut().len(), 1);
2078    }
2079
2080    #[test]
2081    fn condition_forwards_its_childs_active_capability() {
2082        let node = ConditionNode::new(
2083            ConditionKind::ControllerModeIs(ControllerModeKind::Locomotion),
2084            Box::new(CapabilityBehavior("sweep")),
2085        );
2086        assert_eq!(node.active_capability(), Some("sweep"));
2087    }
2088
2089    #[test]
2090    fn weighted_select_children_mut_exposes_every_child() {
2091        let mut node = WeightedSelectNode::new(vec![
2092            (1.0, Box::new(DummyBehavior) as BoxedBehavior),
2093            (1.0, Box::new(DummyBehavior) as BoxedBehavior),
2094        ]);
2095        assert_eq!(node.children_mut().len(), 2);
2096    }
2097
2098    #[tokio::test]
2099    #[ignore = "Requires Redis infrastructure"]
2100    async fn weighted_select_never_picks_a_zero_weight_child() {
2101        let ctx = crate::test_helpers::create_test_context("weighted_select_zero_weight").await;
2102
2103        // `DummyBehavior::tick` is `unimplemented!()`, so the test would panic if the zero-weight
2104        // child were ever ticked - proving it's never picked, over a resolution cycle that visits
2105        // every *eligible* child.
2106        let mut node = WeightedSelectNode::with_rng(
2107            vec![
2108                (1.0, Box::new(FixedStatusBehavior(NodeStatus::Failure)) as BoxedBehavior),
2109                (0.0, Box::new(DummyBehavior) as BoxedBehavior),
2110            ],
2111            StdRng::seed_from_u64(42),
2112        );
2113
2114        assert_eq!(
2115            node.tick(&ctx).await.unwrap(),
2116            NodeStatus::Failure,
2117            "the only eligible (positive-weight) child fails, and the zero-weight one must not be tried"
2118        );
2119    }
2120
2121    #[tokio::test]
2122    #[ignore = "Requires Redis infrastructure"]
2123    async fn weighted_select_does_not_reroll_away_from_a_running_child() {
2124        use std::sync::atomic::{AtomicUsize, Ordering};
2125        use std::sync::Arc;
2126
2127        let ctx = crate::test_helpers::create_test_context("weighted_select_sticky").await;
2128
2129        let leg_a_ticks = Arc::new(AtomicUsize::new(0));
2130        let leg_b_ticks = Arc::new(AtomicUsize::new(0));
2131
2132        let mut node = WeightedSelectNode::with_rng(
2133            vec![
2134                (
2135                    1.0,
2136                    Box::new(CountedLegBehavior {
2137                        running_ticks_remaining: 1,
2138                        tick_count: leg_a_ticks.clone(),
2139                    }) as BoxedBehavior,
2140                ),
2141                (
2142                    1.0,
2143                    Box::new(CountedLegBehavior {
2144                        running_ticks_remaining: 1,
2145                        tick_count: leg_b_ticks.clone(),
2146                    }) as BoxedBehavior,
2147                ),
2148            ],
2149            StdRng::seed_from_u64(7),
2150        );
2151
2152        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
2153        let a_after_first = leg_a_ticks.load(Ordering::SeqCst);
2154        let b_after_first = leg_b_ticks.load(Ordering::SeqCst);
2155        assert_eq!(
2156            a_after_first + b_after_first,
2157            1,
2158            "exactly one child should have been ticked"
2159        );
2160
2161        // Whichever child was chosen (and is now Running) must be re-ticked next call, not
2162        // re-rolled to the other one.
2163        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
2164        assert_eq!(
2165            leg_a_ticks.load(Ordering::SeqCst) + leg_b_ticks.load(Ordering::SeqCst),
2166            2,
2167            "the same child that was Running must be re-ticked (reaching 2 ticks), not the other \
2168             one (which must remain untouched at 0)"
2169        );
2170        assert!(
2171            leg_a_ticks.load(Ordering::SeqCst) == 0 || leg_b_ticks.load(Ordering::SeqCst) == 0,
2172            "the child that was never chosen must never be ticked at all"
2173        );
2174    }
2175
2176    #[tokio::test]
2177    #[ignore = "Requires Redis infrastructure"]
2178    async fn weighted_select_forwards_the_current_childs_active_capability() {
2179        let ctx = crate::test_helpers::create_test_context("weighted_select_capability").await;
2180
2181        let mut node = WeightedSelectNode::with_rng(
2182            vec![(
2183                1.0,
2184                Box::new(StatusAndCapabilityBehavior {
2185                    status: NodeStatus::Running,
2186                    capability: "patrol",
2187                }) as BoxedBehavior,
2188            )],
2189            StdRng::seed_from_u64(1),
2190        );
2191
2192        assert_eq!(node.active_capability(), None, "no tick has happened yet");
2193
2194        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
2195        assert_eq!(node.active_capability(), Some("patrol"));
2196    }
2197
2198    #[tokio::test]
2199    async fn weighted_select_reset_clears_current_index_and_tried() {
2200        let mut node = WeightedSelectNode::with_rng(
2201            vec![
2202                (1.0, Box::new(DummyBehavior) as BoxedBehavior),
2203                (1.0, Box::new(DummyBehavior) as BoxedBehavior),
2204            ],
2205            StdRng::seed_from_u64(3),
2206        );
2207        node.current_index = Some(1);
2208        node.tried[0] = true;
2209
2210        node.reset().await.unwrap();
2211
2212        assert_eq!(node.current_index, None);
2213        assert!(node.tried.iter().all(|t| !t));
2214    }
2215
2216    #[derive(Debug)]
2217    struct CountingFixedStatusBehavior {
2218        status: NodeStatus,
2219        tick_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
2220    }
2221
2222    #[async_trait]
2223    impl BehaviorNode for CountingFixedStatusBehavior {
2224        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
2225            self.tick_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2226            Ok(self.status)
2227        }
2228    }
2229
2230    #[tokio::test]
2231    #[ignore = "Requires Redis infrastructure"]
2232    async fn cooldown_blocks_reentry_immediately_after_a_terminal_result() {
2233        use std::sync::atomic::{AtomicUsize, Ordering};
2234        use std::sync::Arc;
2235
2236        let ctx = crate::test_helpers::create_test_context("cooldown_blocks").await;
2237
2238        let tick_count = Arc::new(AtomicUsize::new(0));
2239        let mut node = CooldownNode::new(
2240            Box::new(CountingFixedStatusBehavior {
2241                status: NodeStatus::Success,
2242                tick_count: tick_count.clone(),
2243            }),
2244            Duration::from_secs(60),
2245        );
2246
2247        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
2248        assert_eq!(tick_count.load(Ordering::SeqCst), 1);
2249
2250        assert_eq!(
2251            node.tick(&ctx).await.unwrap(),
2252            NodeStatus::Failure,
2253            "within the cooldown window, the node must fail without ticking the child"
2254        );
2255        assert_eq!(
2256            tick_count.load(Ordering::SeqCst),
2257            1,
2258            "the child must not have been ticked again while cooling down"
2259        );
2260    }
2261
2262    #[tokio::test]
2263    #[ignore = "Requires Redis infrastructure"]
2264    async fn cooldown_expires_and_ticking_resumes() {
2265        use std::sync::atomic::{AtomicUsize, Ordering};
2266        use std::sync::Arc;
2267
2268        let ctx = crate::test_helpers::create_test_context("cooldown_expires").await;
2269
2270        let tick_count = Arc::new(AtomicUsize::new(0));
2271        let mut node = CooldownNode::new(
2272            Box::new(CountingFixedStatusBehavior {
2273                status: NodeStatus::Success,
2274                tick_count: tick_count.clone(),
2275            }),
2276            Duration::ZERO,
2277        );
2278
2279        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
2280        assert_eq!(tick_count.load(Ordering::SeqCst), 1);
2281
2282        // A zero-length cooldown has already elapsed by the time the next tick() call checks it.
2283        assert_eq!(
2284            node.tick(&ctx).await.unwrap(),
2285            NodeStatus::Success,
2286            "once the cooldown window elapses, ticking must resume normally"
2287        );
2288        assert_eq!(tick_count.load(Ordering::SeqCst), 2);
2289    }
2290
2291    #[tokio::test]
2292    #[ignore = "Requires Redis infrastructure"]
2293    async fn cooldown_does_not_start_while_the_child_is_running() {
2294        use std::sync::atomic::{AtomicUsize, Ordering};
2295        use std::sync::Arc;
2296
2297        let ctx = crate::test_helpers::create_test_context("cooldown_running").await;
2298
2299        let tick_count = Arc::new(AtomicUsize::new(0));
2300        let mut node = CooldownNode::new(
2301            Box::new(CountingFixedStatusBehavior {
2302                status: NodeStatus::Running,
2303                tick_count: tick_count.clone(),
2304            }),
2305            Duration::from_secs(60),
2306        );
2307
2308        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
2309        assert_eq!(tick_count.load(Ordering::SeqCst), 1);
2310
2311        assert_eq!(
2312            node.tick(&ctx).await.unwrap(),
2313            NodeStatus::Running,
2314            "Running must not start the cooldown clock, so the child must be ticked again"
2315        );
2316        assert_eq!(tick_count.load(Ordering::SeqCst), 2);
2317    }
2318
2319    #[test]
2320    fn cooldown_children_mut_exposes_the_child() {
2321        let mut node = CooldownNode::new(Box::new(DummyBehavior), Duration::from_secs(1));
2322        assert_eq!(node.children_mut().len(), 1);
2323    }
2324
2325    #[test]
2326    fn cooldown_forwards_its_childs_active_capability() {
2327        let node = CooldownNode::new(Box::new(CapabilityBehavior("sweep")), Duration::from_secs(1));
2328        assert_eq!(node.active_capability(), Some("sweep"));
2329    }
2330
2331    #[test]
2332    fn cooldown_reset_clears_cooldown_until() {
2333        let mut node = CooldownNode::new(Box::new(DummyBehavior), Duration::from_secs(1));
2334        node.cooldown_until = Some(Instant::now() + Duration::from_secs(60));
2335
2336        node.reset_self();
2337
2338        assert_eq!(node.cooldown_until, None);
2339    }
2340}