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
//! JSON configuration types for behavior composition
//!
//! This module provides types for defining behavior trees in JSON configuration files.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A behavior tree definition loaded from a JSON configuration file.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BehaviorConfig {
/// URL of the JSON schema this configuration conforms to.
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
#[schemars(example = &"https://mecha10.dev/schemas/behavior-composition-v1.json")]
pub schema: Option<String>,
/// Name of this behavior.
#[schemars(example = &"patrol_mission")]
pub name: String,
/// Human-readable description of what this behavior does.
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(example = &"Patrol behavior with safety layer")]
pub description: Option<String>,
/// The root node of the behavior tree.
pub root: CompositionConfig,
/// Named configuration values referenced by nodes in the behavior tree via `config_ref`.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[schemars(example = HashMap::from([
("safety".to_string(), serde_json::json!({"max_speed": 1.0})),
("detector".to_string(), serde_json::json!({"confidence": 0.7})),
("wander".to_string(), serde_json::json!({"speed": 0.5})),
]))]
pub configs: HashMap<String, serde_json::Value>,
}
/// A node in a behavior tree: either a composite control-flow node or a leaf behavior node.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CompositionConfig {
/// Runs its children in order, stopping early if one fails.
Sequence {
/// Child nodes to run in order.
children: Vec<CompositionConfig>,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Runs its children in order, stopping early if one succeeds.
Selector {
/// Child nodes to try in order.
children: Vec<CompositionConfig>,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Runs all of its children concurrently.
Parallel {
/// Child nodes to run concurrently.
children: Vec<CompositionConfig>,
/// Policy determining when this node succeeds based on its children's outcomes.
#[serde(default = "default_parallel_policy")]
policy: ParallelPolicyConfig,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Like `Parallel`, but every child is re-ticked every call regardless of its previous
/// status - no "frozen once resolved" memory. Use this (not `Parallel`) for a condition that
/// must be re-evaluated indefinitely, e.g. inside a `Subsumption` layer that needs to flip
/// between claiming and ceding control many times over a session.
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 one to return `Running`/`Success` "wins" and its status is returned for
/// this tick (lower-priority layers aren't ticked at all that cycle, so their own internal
/// progress - e.g. a `Sequence`'s current child index - is naturally preserved rather than
/// reset, which is what gives a preempted lower layer "resume where it left off" semantics
/// for free). A layer returns `Failure` to cede control to the next layer down, within the
/// *same* tick - this matters: if every layer fails, `Subsumption` itself returns `Failure`,
/// which the top-level executor treats as "whole tree finished, reset everything" - so a
/// layer meant to cede control temporarily (not finish the whole tree) must never be the sole
/// reason the overall tick ends in `Failure` while a lower layer still has work to do; in
/// practice this means: as long as there's always at least one always-available fallback
/// layer (e.g. the lowest-priority layer is something like `wander`/`patrol` that's
/// essentially never `Failure`), a higher layer ceding just causes an immediate same-tick
/// fallthrough to that layer instead, and `Subsumption` returns *that* layer's status
/// (typically `Running`) - never reaching the "all layers failed" case at all.
Subsumption {
/// Priority layers, highest priority first (ties broken by declaration order).
layers: Vec<SubsumptionLayerConfig>,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Negates its child's `Success`/`Failure` result; `Running` passes through unchanged.
Inverter {
/// The single child node to invert.
child: Box<CompositionConfig>,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Reports `Success` once its child resolves, regardless of the child's actual result;
/// `Running` passes through unchanged. Useful for an optional/best-effort step whose
/// failure shouldn't abort a surrounding `Sequence`.
ForceSuccess {
/// The single child node to force-succeed.
child: Box<CompositionConfig>,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Reports `Failure` once its child resolves, regardless of the child's actual result;
/// `Running` passes through unchanged. The mirror of `ForceSuccess`.
ForceFailure {
/// The single child node to force-fail.
child: Box<CompositionConfig>,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Re-runs its child every time it succeeds, up to an optional fixed `count`; a `Failure`
/// ends the loop immediately with `Failure`. Omit `count` to repeat indefinitely.
Repeat {
/// The single child node to repeat.
child: Box<CompositionConfig>,
/// Number of successful runs required before this node succeeds. Omit to repeat
/// indefinitely (the node only ever stops via the child failing).
#[serde(skip_serializing_if = "Option::is_none")]
count: Option<u32>,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Re-runs its child up to `max_attempts` times whenever it fails, succeeding as soon as one
/// attempt succeeds; only fails once every attempt is exhausted.
Retry {
/// The single child node to retry.
child: Box<CompositionConfig>,
/// Maximum number of attempts (must be at least 1).
#[schemars(example = &3)]
max_attempts: u32,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Bounds how long its child may stay `Running`: fails immediately once `timeout_secs` have
/// elapsed since this node started ticking the child (without resolving).
Timeout {
/// The single child node to bound.
child: Box<CompositionConfig>,
/// Maximum time, in seconds, the child is allowed to stay `Running`.
#[schemars(example = &30.0)]
timeout_secs: f64,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// A leaf node that runs a single behavior node implementation.
Node {
/// Name of the behavior node type to run.
#[schemars(example = &"wander")]
node: String,
/// Key into the top-level `configs` map to use as this node's configuration.
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(example = &"wander")]
config_ref: Option<String>,
/// Inline configuration for this node.
#[serde(skip_serializing_if = "Option::is_none")]
config: Option<serde_json::Value>,
/// Optional name for this node.
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
}
fn default_parallel_policy() -> ParallelPolicyConfig {
ParallelPolicyConfig::RequireAll
}
/// Determines when a `Parallel` composition node succeeds, based on its children's outcomes.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ParallelPolicyConfig {
/// Succeeds only once all children succeed.
RequireAll,
/// Succeeds once any one child succeeds.
RequireOne,
/// Succeeds once at least the given number of children succeed.
RequireN(usize),
}
impl From<ParallelPolicyConfig> for crate::composition::ParallelPolicy {
fn from(config: ParallelPolicyConfig) -> Self {
match config {
ParallelPolicyConfig::RequireAll => crate::composition::ParallelPolicy::RequireAll,
ParallelPolicyConfig::RequireOne => crate::composition::ParallelPolicy::RequireOne,
ParallelPolicyConfig::RequireN(n) => crate::composition::ParallelPolicy::RequireN(n),
}
}
}
/// A single priority layer within a `Subsumption` composition node.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SubsumptionLayerConfig {
/// Layer priority - higher wins. Layers are tried highest-first each tick.
pub priority: u8,
/// The composition node implementing this layer.
pub node: CompositionConfig,
/// Optional name for this layer, used for debugging/logging.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
/// Configuration referencing a behavior node type and its inline configuration.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct NodeConfig {
/// Name of the behavior node type.
#[schemars(example = &"wander")]
pub node: String,
/// Inline configuration for this node.
#[serde(default)]
pub config: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct NodeReference {
pub node_type: String,
pub config: serde_json::Value,
pub name: Option<String>,
}
impl BehaviorConfig {
/// Load a behavior configuration from a JSON string.
///
/// # Example
///
/// ```rust
/// use mecha10_behavior_runtime::BehaviorConfig;
///
/// let json = r#"{
/// "name": "test_behavior",
/// "root": {
/// "type": "sequence",
/// "children": []
/// }
/// }"#;
///
/// let config = BehaviorConfig::from_json(json).unwrap();
/// assert_eq!(config.name, "test_behavior");
/// ```
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, `super::validator`'s runtime validator
/// is built from, and `cargo run --example export_schemas`
/// (`packages/cli/examples/export_schemas.rs`) renders to `schemas/behavior.schema.json`,
/// the single standard-named `schemas/` source of truth for this type (LAB-4245 folded
/// the old separate `cargo run --example generate_schema` →
/// `schemas/behavior_composition_v1.json` path into this one).
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)
}
}