///|
/// Programmatic builder for users who prefer typed MoonBit code over DSL text.
pub(all) struct TreeBuilder {
root : String
mut nodes : Array[BtNode]
mut notes : Array[String]
} derive(Debug, Eq)
///|
pub fn new_builder(root : String) -> TreeBuilder {
{ root, nodes: Array::new(), notes: Array::new() }
}
///|
pub fn TreeBuilder::add_node(
self : TreeBuilder,
n : BtNode,
) -> Result[TreeBuilder, BtError] {
if self.has_node(n.id) {
Err(DuplicateNode(n.id))
} else {
self.nodes.push(n)
Ok(self)
}
}
///|
pub fn TreeBuilder::sequence(
self : TreeBuilder,
id : String,
children : Array[String],
) -> Result[TreeBuilder, BtError] {
self.add_node(node(id, Sequence, children~))
}
///|
pub fn TreeBuilder::selector(
self : TreeBuilder,
id : String,
children : Array[String],
) -> Result[TreeBuilder, BtError] {
self.add_node(node(id, Selector, children~))
}
///|
pub fn TreeBuilder::parallel_all(
self : TreeBuilder,
id : String,
children : Array[String],
) -> Result[TreeBuilder, BtError] {
self.add_node(node(id, ParallelAll, children~))
}
///|
pub fn TreeBuilder::parallel_any(
self : TreeBuilder,
id : String,
children : Array[String],
) -> Result[TreeBuilder, BtError] {
self.add_node(node(id, ParallelAny, children~))
}
///|
pub fn TreeBuilder::decorator(
self : TreeBuilder,
id : String,
kind : NodeKind,
child : String,
) -> Result[TreeBuilder, BtError] {
self.add_node(node(id, kind, children=[child]))
}
///|
pub fn TreeBuilder::condition(
self : TreeBuilder,
id : String,
key : String,
op : CompareOp,
expected : BtValue,
) -> Result[TreeBuilder, BtError] {
self.add_node(condition(id, key, op, expected))
}
///|
pub fn TreeBuilder::set(
self : TreeBuilder,
id : String,
key : String,
value : BtValue,
) -> Result[TreeBuilder, BtError] {
self.add_node(set_value(id, key, value))
}
///|
pub fn TreeBuilder::wait(
self : TreeBuilder,
id : String,
ticks : Int,
) -> Result[TreeBuilder, BtError] {
self.add_node(wait(id, ticks))
}
///|
pub fn TreeBuilder::action(
self : TreeBuilder,
id : String,
name : String,
statuses : Array[BtStatus],
writes? : Array[(String, BtValue)],
) -> Result[TreeBuilder, BtError] {
self.add_node(node(id, ActionPlan(name, statuses, writes.unwrap_or([]))))
}
///|
pub fn TreeBuilder::emit(
self : TreeBuilder,
id : String,
label : String,
) -> Result[TreeBuilder, BtError] {
self.add_node(node(id, Emit(label)))
}
///|
pub fn TreeBuilder::note(self : TreeBuilder, text : String) -> TreeBuilder {
self.notes.push(text)
self
}
///|
pub fn TreeBuilder::finish(self : TreeBuilder) -> Result[BehaviorTree, BtError] {
let tree = tree_from_nodes(self.root, self.nodes)
let report = tree.validate()
if report.ok {
Ok(tree)
} else {
Err(InvalidTree(report.message()))
}
}
///|
pub fn TreeBuilder::has_node(self : TreeBuilder, id : String) -> Bool {
let mut i = 0
while i < self.nodes.length() {
if self.nodes[i].id == id {
return true
}
i = i + 1
}
false
}
///|
pub fn TreeBuilder::node_count(self : TreeBuilder) -> Int {
self.nodes.length()
}
///|
pub fn TreeBuilder::note_lines(self : TreeBuilder) -> Array[String] {
let out = Array::new()
let mut i = 0
while i < self.notes.length() {
out.push(self.notes[i])
i = i + 1
}
out
}
///|
pub(all) struct RecipeBundle {
name : String
description : String
tree : BehaviorTree
blackboard : Blackboard
expected : BtStatus
max_ticks : Int
} derive(Debug, Eq)
///|
pub fn RecipeBundle::summary(self : RecipeBundle) -> String {
self.name +
": nodes=" +
self.tree.node_count().to_string() +
", expected=" +
self.expected.to_text() +
", board=" +
self.blackboard.len().to_string()
}
///|
pub fn RecipeBundle::run(self : RecipeBundle) -> RunSummary {
smoke_run(self.name, self.tree, self.blackboard, self.expected, max_ticks=self.max_ticks)
}
///|
pub(all) struct CombatRecipeOptions {
enemy_visible : Bool
ammo : Int
stamina : Int
aim_ticks : Int
reload_ticks : Int
} derive(Debug, Eq)
///|
pub fn default_combat_options() -> CombatRecipeOptions {
{ enemy_visible: true, ammo: 2, stamina: 8, aim_ticks: 1, reload_ticks: 2 }
}
///|
pub fn combat_recipe(options? : CombatRecipeOptions) -> Result[RecipeBundle, BtError] {
let opts = options.unwrap_or(default_combat_options())
let builder = new_builder("root")
ignore(builder.selector("root", ["engage", "reload_branch", "patrol"]))
ignore(builder.sequence("engage", ["can_see", "has_ammo", "has_stamina", "aim", "fire"]))
ignore(builder.condition("can_see", "enemy_visible", Eq, BoolValue(true)))
ignore(builder.condition("has_ammo", "ammo", Greater, IntValue(0)))
ignore(builder.condition("has_stamina", "stamina", GreaterEq, IntValue(5)))
ignore(
builder.action(
"aim",
"aim_weapon",
running_then_success(opts.aim_ticks),
writes=[("mode", TextValue("aiming"))],
),
)
ignore(builder.action("fire", "fire_weapon", [Success], writes=[("mode", TextValue("fire"))]))
ignore(builder.sequence("reload_branch", ["empty_ammo", "reload"]))
ignore(builder.condition("empty_ammo", "ammo", Eq, IntValue(0)))
ignore(
builder.action(
"reload",
"reload_weapon",
running_then_success(opts.reload_ticks),
writes=[("ammo", IntValue(3))],
),
)
ignore(builder.action("patrol", "patrol_area", [Success], writes=[("mode", TextValue("patrol"))]))
match builder.finish() {
Ok(tree) =>
Ok({
name: "combat",
description: "Parameterized game combat behavior tree.",
tree,
blackboard: blackboard_from_pairs([
("enemy_visible", BoolValue(opts.enemy_visible)),
("ammo", IntValue(opts.ammo)),
("stamina", IntValue(opts.stamina)),
]),
expected: Success,
max_ticks: 12,
})
Err(err) => Err(err)
}
}
///|
pub(all) struct RobotRecipeOptions {
battery : Int
package_ready : Bool
charge_threshold : Int
charge_ticks : Int
navigate_ticks : Int
} derive(Debug, Eq)
///|
pub fn default_robot_options() -> RobotRecipeOptions {
{
battery: 20,
package_ready: true,
charge_threshold: 30,
charge_ticks: 2,
navigate_ticks: 2,
}
}
///|
pub fn robot_recipe(options? : RobotRecipeOptions) -> Result[RecipeBundle, BtError] {
let opts = options.unwrap_or(default_robot_options())
let builder = new_builder("root")
ignore(builder.selector("root", ["charge_first", "deliver", "standby"]))
ignore(builder.sequence("charge_first", ["battery_low", "dock", "charge"]))
ignore(builder.condition("battery_low", "battery", Less, IntValue(opts.charge_threshold)))
ignore(builder.action("dock", "dock", [Running, Success], writes=[("docked", BoolValue(true))]))
ignore(
builder.action(
"charge",
"charge",
running_then_success(opts.charge_ticks),
writes=[("battery", IntValue(100)), ("docked", BoolValue(false))],
),
)
ignore(builder.sequence("deliver", ["package_ready", "pickup", "navigate", "dropoff"]))
ignore(builder.condition("package_ready", "package_ready", Eq, BoolValue(true)))
ignore(builder.action("pickup", "pickup", [Success], writes=[("carrying", BoolValue(true))]))
ignore(
builder.action(
"navigate",
"navigate",
running_then_success(opts.navigate_ticks),
writes=[("location", TextValue("target"))],
),
)
ignore(builder.action("dropoff", "dropoff", [Success], writes=[("delivered", BoolValue(true))]))
ignore(builder.action("standby", "standby", [Success], writes=[("state", TextValue("standby"))]))
match builder.finish() {
Ok(tree) =>
Ok({
name: "robot",
description: "Parameterized warehouse robot task behavior tree.",
tree,
blackboard: blackboard_from_pairs([
("battery", IntValue(opts.battery)),
("package_ready", BoolValue(opts.package_ready)),
]),
expected: Success,
max_ticks: 16,
})
Err(err) => Err(err)
}
}
///|
pub(all) struct AgentRecipeOptions {
needs_current_info : Bool
tool_allowed : Bool
ambiguity : Int
} derive(Debug, Eq)
///|
pub fn default_agent_options() -> AgentRecipeOptions {
{ needs_current_info: true, tool_allowed: true, ambiguity: 1 }
}
///|
pub fn agent_recipe(options? : AgentRecipeOptions) -> Result[RecipeBundle, BtError] {
let opts = options.unwrap_or(default_agent_options())
let builder = new_builder("root")
ignore(builder.selector("root", ["research", "direct_answer", "clarify"]))
ignore(builder.sequence("research", ["needs_info", "tool_ok", "search", "compose"]))
ignore(builder.condition("needs_info", "needs_current_info", Eq, BoolValue(true)))
ignore(builder.condition("tool_ok", "tool_allowed", Eq, BoolValue(true)))
ignore(builder.action("search", "search", [Running, Success], writes=[("evidence", TextValue("ready"))]))
ignore(builder.action("compose", "compose", [Success], writes=[("answer", TextValue("ready"))]))
ignore(builder.sequence("direct_answer", ["low_ambiguity", "draft"]))
ignore(builder.condition("low_ambiguity", "ambiguity", LessEq, IntValue(2)))
ignore(builder.action("draft", "draft", [Success], writes=[("answer", TextValue("direct"))]))
ignore(builder.action("clarify", "clarify", [Success], writes=[("answer", TextValue("needs_input"))]))
match builder.finish() {
Ok(tree) =>
Ok({
name: "agent",
description: "AI-agent style decision tree with research, direct answer, and clarification paths.",
tree,
blackboard: blackboard_from_pairs([
("needs_current_info", BoolValue(opts.needs_current_info)),
("tool_allowed", BoolValue(opts.tool_allowed)),
("ambiguity", IntValue(opts.ambiguity)),
]),
expected: Success,
max_ticks: 10,
})
Err(err) => Err(err)
}
}
///|
pub fn recipe_catalog() -> Array[RecipeBundle] {
let out = Array::new()
match combat_recipe() {
Ok(bundle) => out.push(bundle)
Err(_) => ()
}
match robot_recipe() {
Ok(bundle) => out.push(bundle)
Err(_) => ()
}
match agent_recipe() {
Ok(bundle) => out.push(bundle)
Err(_) => ()
}
out
}
///|
pub fn run_recipe_catalog() -> Array[RunSummary] {
let bundles = recipe_catalog()
let out = Array::new()
let mut i = 0
while i < bundles.length() {
out.push(bundles[i].run())
i = i + 1
}
out
}
///|
pub fn recipe_report_lines() -> Array[String] {
let bundles = recipe_catalog()
let lines = Array::new()
lines.push("MoonBTKit recipe report")
let mut i = 0
while i < bundles.length() {
lines.push(bundles[i].summary())
lines.push(bundles[i].run().to_line())
i = i + 1
}
lines
}
///|
pub fn recipe_to_case(bundle : RecipeBundle) -> CaseSpec {
{
name: "recipe_" + bundle.name,
dsl: serialize_tree(bundle.tree).join("\n") +
"\n" +
serialize_blackboard(bundle.blackboard).join("\n"),
expectation: {
final_status: bundle.expected,
required_board: [],
max_ticks: bundle.max_ticks,
},
}
}
///|
pub fn recipe_cases() -> Array[CaseSpec] {
let bundles = recipe_catalog()
let cases = Array::new()
let mut i = 0
while i < bundles.length() {
cases.push(recipe_to_case(bundles[i]))
i = i + 1
}
cases
}
///|
pub fn running_then_success(running_ticks : Int) -> Array[BtStatus] {
let out = Array::new()
let mut i = 0
while i < running_ticks {
out.push(Running)
i = i + 1
}
out.push(Success)
out
}
///|
pub fn running_then_failure(running_ticks : Int) -> Array[BtStatus] {
let out = Array::new()
let mut i = 0
while i < running_ticks {
out.push(Running)
i = i + 1
}
out.push(Failure)
out
}
///|
pub fn repeat_status(status : BtStatus, count : Int) -> Array[BtStatus] {
let out = Array::new()
let mut i = 0
while i < count {
out.push(status)
i = i + 1
}
out
}
///|
pub fn append_status(
base : Array[BtStatus],
tail : BtStatus,
) -> Array[BtStatus] {
let out = Array::new()
let mut i = 0
while i < base.length() {
out.push(base[i])
i = i + 1
}
out.push(tail)
out
}