mecha10-behavior-runtime 4.2.11

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! JSON configuration types for behavior composition
//!
//! This module provides types for defining behavior trees in JSON configuration files.

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

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

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

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

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

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

    /// Capabilities (see `mecha10_core::node::Capability`) this behavior composition requires
    /// the project to provide. Checked at validation time against `ProjectConfig::capabilities`
    /// -- the union of every enabled node's declared capabilities, flattened out of the
    /// per-node `nodes.<id>.capabilities` shape. Empty by default, meaning this behavior has no
    /// capability requirements.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[schemars(example = &vec![mecha10_core::node::Capability::Locomotion])]
    pub requires_capabilities: Vec<mecha10_core::node::Capability>,
}

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

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

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

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

    /// Like `Selector`, but instead of always trying children in a fixed index order, picks the
    /// next untried child via a weighted-random draw among children not yet tried this
    /// resolution cycle. A child's `weight` of `0.0` (or negative) means it is never picked.
    /// Otherwise behaves exactly like `Selector`: a chosen child returning `Running` is re-ticked
    /// next tick (not re-rolled); a chosen child returning `Success` resolves the whole node to
    /// `Success`; once every positive-weight child has been tried and failed, the node fails.
    WeightedSelect {
        /// Child nodes, each with a weight controlling how likely it is to be tried next.
        children: Vec<WeightedChildConfig>,

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /// Bounds the GAP between runs of its child - the complement of `Timeout` (which bounds how
    /// long a single run may stay `Running`). Once the child returns a terminal result
    /// (`Success`/`Failure`), refuses to re-tick it for `cooldown_secs`: `tick()` returns
    /// `Failure` immediately, without ticking the child at all, until the window elapses. A
    /// child that returns `Running` does not start the cooldown clock.
    Cooldown {
        /// The single child node to cool down between runs of.
        child: Box<CompositionConfig>,

        /// How long, in seconds, to refuse to re-tick the child after it resolves.
        #[schemars(example = &5.0)]
        cooldown_secs: f64,

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

    /// Gates its single child on a `condition` predicate: evaluates the predicate first and only
    /// ticks the child (forwarding its status) while it holds; reports `Failure` without ticking
    /// the child when it doesn't. A small, reusable building block - dropping several
    /// `condition`-wrapped children into a `selector` gives capability- or state-gated branch
    /// selection, since the selector advances past any `Failure` child to the next option.
    Condition {
        /// The predicate to evaluate before ticking `child`.
        #[schemars(example = &serde_json::json!({"controller_mode_is": "locomotion"}))]
        condition: ConditionConfig,

        /// The single child node to gate.
        child: Box<CompositionConfig>,

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

    /// Inlines another composition node loaded from a separate JSON file, so multiple behaviors
    /// can share one subtree definition (e.g. a common interrupt layer) without duplicating it
    /// in every `config.json`. The referenced file's content must be a single bare
    /// `CompositionConfig` JSON object - the same tagged `"type"` shape as any other node in a
    /// tree - NOT a full `BehaviorConfig` envelope (no `name`/`configs`/`$schema` wrapper).
    ///
    /// Only supported when the behavior tree itself was loaded from a file (`BehaviorLoader::
    /// load_from_file`); `path` is resolved relative to the directory containing the config.json
    /// this `include` node appears in (itself, if the fragment nests further includes) - NOT
    /// relative to the current working directory. Loading via `load_from_json`/`load` with a
    /// bare in-memory `BehaviorConfig` has no base directory to resolve against and fails if the
    /// tree contains an `include` node.
    Include {
        /// Path to the fragment file, relative to the including file's own directory.
        #[schemars(example = &"../_shared/instance_track_interrupt.json")]
        path: String,

        /// Optional name for this node (describes the include site, not the fragment itself).
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

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

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

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

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

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

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

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

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

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

/// The predicate a `Condition` composition node evaluates before ticking its child. A small,
/// closed set - not a general expression language - extended with a new variant whenever a new
/// predicate is genuinely needed. Maps to `crate::composition::ConditionKind`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConditionConfig {
    /// True when the project's current `mecha10_core::config::controller_mode()` is the given
    /// variant. Serialized as e.g. `{"controller_mode_is": "locomotion"}`.
    ControllerModeIs(ControllerModeKindConfig),
}

/// Which controller-mode variant a [`ConditionConfig::ControllerModeIs`] predicate matches -
/// mirrors `mecha10_core::config::ControllerMode`'s variant names (the `Joints` joint-name list
/// is irrelevant to the check, so it carries no data here). Serialized as `"locomotion"` or
/// `"joints"`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ControllerModeKindConfig {
    Locomotion,
    Joints,
}

impl From<ConditionConfig> for crate::composition::ConditionKind {
    fn from(config: ConditionConfig) -> Self {
        match config {
            ConditionConfig::ControllerModeIs(kind) => crate::composition::ConditionKind::ControllerModeIs(kind.into()),
        }
    }
}

impl From<ControllerModeKindConfig> for crate::composition::ControllerModeKind {
    fn from(config: ControllerModeKindConfig) -> Self {
        match config {
            ControllerModeKindConfig::Locomotion => crate::composition::ControllerModeKind::Locomotion,
            ControllerModeKindConfig::Joints => crate::composition::ControllerModeKind::Joints,
        }
    }
}

/// A single weighted child within a `WeightedSelect` composition node.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WeightedChildConfig {
    /// Weight controlling how likely this child is to be drawn next, relative to its untried
    /// siblings. A weight of `0.0` (or negative) means this child is never picked.
    #[schemars(example = &1.0)]
    pub weight: f64,

    /// The composition node for this child.
    pub node: CompositionConfig,
}

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

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

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

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

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

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

    pub config: serde_json::Value,

    pub name: Option<String>,
}

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

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

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

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

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

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