diff --git a/.github/workflows/rust-pr.yml b/.github/workflows/rust-pr.yml index 87cc8f2..5026a5f 100644 --- a/.github/workflows/rust-pr.yml +++ b/.github/workflows/rust-pr.yml @@ -13,9 +13,9 @@ jobs: steps: - name: Install libudev run: sudo apt-get update && sudo apt-get install libudev-dev pkg-config librust-alsa-sys-dev - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: pre-commit/action@v2.0.3 + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-python@v6.2.0 + - uses: pre-commit/action@v3.0.1 build: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index c363987..729cee6 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ For example, if you have a state `A` and a state `B`: - Do `A`, `B` repeatedly while `LoopCondition` runs: `WhileAll(LoopCondition, [A, B])`. After *All* nodes `A`, `B` are completed successfully, check the condition node. - Run `A` and `B` in parallell and wait for both to succeed: `WhenAll([A, B])` - Run `A` and `B` in parallell and wait for any to succeed: `WhenAny([A, B])` +- Run `A` and `B` in parallell and wait for any to complete regardless of success or failure: `Race([A, B])` - Run `A` and `B` in parallell, but `A` has to succeed before `B`: `After([A, B])` See the `Behavior` enum for more information. diff --git a/bonsai/Cargo.toml b/bonsai/Cargo.toml index 242f6fe..f697be9 100644 --- a/bonsai/Cargo.toml +++ b/bonsai/Cargo.toml @@ -12,7 +12,7 @@ name = "bonsai-bt" readme = "../README.md" repository = "https://github.com/sollimann/bonsai.git" rust-version = "1.80.0" -version = "0.10.0" +version = "0.11.0" [lib] name = "bonsai_bt" diff --git a/bonsai/src/behavior.rs b/bonsai/src/behavior.rs index 783fbe4..6820f04 100644 --- a/bonsai/src/behavior.rs +++ b/bonsai/src/behavior.rs @@ -128,6 +128,12 @@ pub enum Behavior { /// Succeeds if all behaviors succeed, but only if succeeding in sequence. /// Fails if one behavior fails. After(Vec>), + /// Runs all behaviors in parallel until one completes (succeeds or fails). + /// + /// Returns the status of the first behavior to complete, + /// whether that is `Success` or `Failure`. + /// If all behaviors remain `Running`, returns `Running`. + Race(Vec>), } #[cfg(test)] diff --git a/bonsai/src/lib.rs b/bonsai/src/lib.rs index 0ab85c2..9e92716 100644 --- a/bonsai/src/lib.rs +++ b/bonsai/src/lib.rs @@ -24,6 +24,7 @@ //! - Do `A`, `B` forever: `While(WaitForever, [A, B])` //! - Run `A` and `B` in parallell and wait for both to succeed: `WhenAll([A, B])` //! - Run `A` and `B` in parallell and wait for any to succeed: `WhenAny([A, B])` +//! - Run `A` and `B` in parallell and wait for any to complete regardless of success or failure: `Race([A, B])` //! - Run `A` and `B` in parallell, but `A` has to succeed before `B`: `After([A, B])` //! //! See the `Behavior` enum for more information. @@ -118,7 +119,7 @@ //! ``` pub use behavior::Behavior::{ - self, Action, After, AlwaysSucceed, If, Invert, Select, Sequence, Wait, WaitForever, WhenAll, WhenAny, While, + self, Action, After, AlwaysSucceed, If, Invert, Race, Select, Sequence, Wait, WaitForever, WhenAll, WhenAny, While, WhileAll, }; diff --git a/bonsai/src/state.rs b/bonsai/src/state.rs index 2c7365f..a9f6983 100644 --- a/bonsai/src/state.rs +++ b/bonsai/src/state.rs @@ -102,6 +102,8 @@ pub(crate) enum State { /// Keeps track of a `WhenAny` behavior. As the states finish, they are set /// to [`None`]. WhenAny(Vec>>), + /// Keeps track of a `Race` behavior. + Race(Vec>>), /// Keeps track of an `After` behavior. After { /// The index of the next state that must succeed. @@ -166,6 +168,7 @@ impl State { } Behavior::WhenAll(all) => State::WhenAll(all.into_iter().map(|ev| Some(State::new(ev))).collect()), Behavior::WhenAny(any) => State::WhenAny(any.into_iter().map(|ev| Some(State::new(ev))).collect()), + Behavior::Race(behaviors) => State::Race(behaviors.into_iter().map(|ev| Some(State::new(ev))).collect()), Behavior::After(after_all) => State::After { next_success_index: 0, states: after_all.into_iter().map(State::new).collect(), @@ -404,6 +407,20 @@ impl State { let any = true; when_all(any, upd, cursors, e, f, blackboard) } + (_, &mut Race(ref mut cursors)) => { + // return the result of the first child to complete, + // regardless of whether it succeeds or fails. + for cur in cursors.iter_mut() { + match *cur { + None => {} + Some(ref mut state) => match state.tick(e, blackboard, f) { + (Running, _) => continue, + (status, dt) => return (status, dt), + }, + } + } + RUNNING + } ( _, &mut After { diff --git a/bonsai/src/visualizer.rs b/bonsai/src/visualizer.rs index 1b601c4..727ac63 100644 --- a/bonsai/src/visualizer.rs +++ b/bonsai/src/visualizer.rs @@ -19,6 +19,7 @@ pub(crate) enum NodeType { WhenAll, WhenAny, After, + Race, } impl BT { @@ -125,6 +126,13 @@ impl BT { Self::dfs_recursive(graph, b, node_id) } } + Behavior::Race(behaviors) => { + let node_id = graph.add_node(NodeType::Race); + graph.add_edge(parent_node, node_id, 1); + for b in behaviors { + Self::dfs_recursive(graph, b, node_id) + } + } } } } diff --git a/bonsai/src/when_all.rs b/bonsai/src/when_all.rs index 29503b1..8df34ec 100644 --- a/bonsai/src/when_all.rs +++ b/bonsai/src/when_all.rs @@ -55,6 +55,7 @@ where terminated += 1; *cur = None; } + #[allow(clippy::manual_unwrap_or)] match terminated { // If there are no events, there is a whole 'dt' left. 0 if cursors.is_empty() => ( diff --git a/bonsai/tests/behavior_tests.rs b/bonsai/tests/behavior_tests.rs index fbbe47c..7848c85 100644 --- a/bonsai/tests/behavior_tests.rs +++ b/bonsai/tests/behavior_tests.rs @@ -1,7 +1,7 @@ use crate::behavior_tests::TestActions::{Dec, Inc, LessThan, LessThanRunningSuccess}; use bonsai_bt::{ - Action, ActionArgs, After, AlwaysSucceed, Event, Failure, Float, If, Invert, Select, Sequence, Status::Running, - Success, UpdateArgs, Wait, WaitForever, WhenAll, While, WhileAll, BT, + Action, ActionArgs, After, AlwaysSucceed, Event, Failure, Float, If, Invert, Race, Select, Sequence, + Status::Running, Success, UpdateArgs, Wait, WaitForever, WhenAll, WhenAny, While, WhileAll, BT, }; /// Some test actions. @@ -610,3 +610,95 @@ fn test_repeat_sequence_empty() { // panics because no behaviors... let _state = BT::new(after, ()); } + +#[test] +fn race_returns_first_success() { + let a: i32 = 0; + // Inc succeeds immediately, Wait is still running + let behavior = Race(vec![Action(Inc), Wait(10.0)]); + let mut state = BT::new(behavior, ()); + let (a, s, _) = tick(a, 0.1, &mut state); + assert_eq!(a, 1); + assert_eq!(s, Success); +} + +#[test] +fn race_returns_first_failure() { + let a: i32 = 5; + // LessThan(1) fails immediately since 5 >= 1, Wait is still running + let behavior = Race(vec![Action(LessThan(1)), Wait(10.0)]); + let mut state = BT::new(behavior, ()); + let (a, s, _) = tick(a, 0.1, &mut state); + assert_eq!(a, 5); + assert_eq!(s, Failure); +} + +#[test] +fn race_running_until_first_completes() { + let a: i32 = 0; + // Both children are time-based, neither completes on first tick + let behavior = Race(vec![Wait(1.0), Wait(2.0)]); + let mut state = BT::new(behavior, ()); + + // After 0.5s, both still running + let (_a, s, _) = tick(a, 0.5, &mut state); + assert_eq!(s, Running); + + // After another 0.5s (total 1.0s), first Wait completes with Success + let (_a, s, _) = tick(_a, 0.5, &mut state); + assert_eq!(s, Success); +} + +#[test] +fn race_second_child_wins_if_first_is_running() { + let a: i32 = 0; + let behavior = Race(vec![WaitForever, Action(Inc)]); + let mut state = BT::new(behavior, ()); + let (a, s, _) = tick(a, 0.1, &mut state); + assert_eq!(a, 1); + assert_eq!(s, Success); +} + +#[test] +fn race_failure_short_circuits_unlike_when_any() { + // the main difference from WhenAny: + // WhenAny would swallow the failure and keep running. + // Race returns the failure immediately. + let a: i32 = 5; + // LessThan(1) fails immediately (5 >= 1), Wait(10.0) is still running + let behavior = Race(vec![Action(LessThan(1)), Wait(10.0)]); + let mut state = BT::new(behavior, ()); + let (a, s, _) = tick(a, 0.1, &mut state); + assert_eq!(s, Failure); + + // for WhenAny: same children, but failure is swallowed + let behavior_any = WhenAny(vec![Action(LessThan(1)), Wait(10.0)]); + let mut state_any = BT::new(behavior_any, ()); + let (_, s_any, _) = tick(a, 0.1, &mut state_any); + assert_eq!(s_any, Running); +} + +#[test] +fn race_timeout_pattern() { + let a: i32 = 0; + // Simulate a "slow action" using WaitForever with a 1-second timeout. + // The timeout (Wait) fires first. + let behavior = Race(vec![WaitForever, Wait(1.0)]); + let mut state = BT::new(behavior, ()); + + let (_, s, _) = tick(a, 0.5, &mut state); + assert_eq!(s, Running); + + let (_, s, _) = tick(a, 0.5, &mut state); + assert_eq!(s, Success); +} + +#[test] +fn race_empty() { + let a: i32 = 0; + let behavior = Race(vec![]); + let mut state = BT::new(behavior, ()); + let (_, s, _) = tick(a, 0.1, &mut state); + // No children means nothing can complete, stays Running + assert_eq!(s, Running); +} diff --git a/docs/concepts/README.md b/docs/concepts/README.md index 51f73d6..b87c336 100644 --- a/docs/concepts/README.md +++ b/docs/concepts/README.md @@ -56,6 +56,7 @@ For example, if you have a state `A` and a state `B`: - Do `A`, `B` forever: `While(WaitForever, [A, B])` - Run `A` and `B` in parallel and wait for both to succeed: `WhenAll([A, B])` - Run `A` and `B` in parallel and wait for any to succeed: `WhenAny([A, B])` +- Run `A` and `B` in parallell and wait for any to complete regardless of success or failure: `Race([A, B])` - Run `A` and `B` in parallel, but `A` has to succeed before `B`: `After([A, B])` See the `Behavior` enum for more information. diff --git a/docs/resources/images/race_timeout.png b/docs/resources/images/race_timeout.png new file mode 100644 index 0000000..5713e0b Binary files /dev/null and b/docs/resources/images/race_timeout.png differ diff --git a/examples/Cargo.toml b/examples/Cargo.toml index fd94ad5..7fea124 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -47,3 +47,7 @@ path = "src/graphviz/main.rs" [[bin]] name = "simple_npc_ai" path = "src/simple_npc_ai/main.rs" + +[[bin]] +name = "race_timeout" +path = "src/race_timeout/main.rs" diff --git a/examples/README.md b/examples/README.md index 66af43f..c129b6d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -65,3 +65,13 @@ Compile the behavior tree into a [graphviz](https://graphviz.org/) compatible [D

+ +## Behavior Timeout (example for Race behavior and long-running jobs) + +This simple example shows an example of using the Race behavior to time out a long-running job that takes a random amount of time to complete. + +`cargo run --bin race_timeout` + +

+ +

diff --git a/examples/src/race_timeout/main.rs b/examples/src/race_timeout/main.rs new file mode 100644 index 0000000..5f3bc02 --- /dev/null +++ b/examples/src/race_timeout/main.rs @@ -0,0 +1,110 @@ +use bonsai_bt::Behavior::Wait; +use bonsai_bt::{ + Behavior::Action, Behavior::Race, Behavior::Sequence, Event, Float, Status, Timer, UpdateArgs, BT, RUNNING, +}; +use futures::FutureExt; +use rand::Rng; +use std::collections::HashMap; +use std::sync::mpsc::{channel, Receiver}; +use std::thread::sleep; +use std::time::Duration; +use tokio::time::sleep as async_sleep; + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub enum MissionAction { + /// The main job that finishes after a random delay + DoWork, + /// Hard deadline that fires after a fixed delay + OnTimeout, +} + +pub struct MissionState { + pub work: Option>, +} + +/// Simulates a unit of work whose duration is random. +/// Sometimes it finishes before the timeout and sometimes it doesn't. +async fn do_work_task(tx: std::sync::mpsc::Sender) { + let work_ms: u64 = rand::thread_rng().gen_range(200..=1200); + println!("[do_work] started."); + + let step = Duration::from_millis(100); + let mut elapsed = 0u64; + while elapsed < work_ms { + if tx.send(Status::Running).is_err() { + println!("[do_work] preempted by timeout, stopping."); + return; + } + async_sleep(step).await; + elapsed += step.as_millis() as u64; + } + + println!("[do_work] finished after {elapsed} ms"); + let _ = tx.send(Status::Success); +} + +async fn tick( + timer: &mut Timer, + state: &mut MissionState, + bt: &mut BT>, +) -> std::option::Option<(Status, Float)> { + let dt: Float = timer.get_dt(); + let e: Event = UpdateArgs { dt }.into(); + + bt.tick( + &e, + &mut |args: bonsai_bt::ActionArgs, _| match *args.action { + MissionAction::DoWork => { + if let Some(rx) = &state.work { + match rx.recv() { + Ok(Status::Running) => RUNNING, + Ok(Status::Success) => { + state.work = None; + (Status::Success, args.dt) + } + Ok(Status::Failure) | Err(_) => { + state.work = None; + (Status::Failure, args.dt) + } + } + } else { + let (tx, rx) = channel(); + let (job, handle) = do_work_task(tx).remote_handle(); + handle.forget(); + tokio::spawn(job); + state.work = Some(rx); + match state.work.as_ref().unwrap().recv().unwrap() { + Status::Running => RUNNING, + s => (s, args.dt), + } + } + } + + MissionAction::OnTimeout => { + eprintln!("do_work timed out!"); + (Status::Failure, args.dt) + } + }, + ) +} + +#[tokio::main] +async fn main() { + const TIMEOUT_S: Float = 0.6; + + let behavior = Sequence(vec![Race(vec![ + Action(MissionAction::DoWork), + Sequence(vec![Wait(TIMEOUT_S), Action(MissionAction::OnTimeout)]), + ])]); + + let mut bt = BT::new(behavior, HashMap::new()); + let mut timer = Timer::init_time(); + let mut state = MissionState { work: None }; + + loop { + sleep(Duration::from_millis(50)); + if tick(&mut timer, &mut state, &mut bt).await.is_none() { + break; + } + } +} diff --git a/examples/src/simple_npc_ai/main.rs b/examples/src/simple_npc_ai/main.rs index 937752c..b868d26 100644 --- a/examples/src/simple_npc_ai/main.rs +++ b/examples/src/simple_npc_ai/main.rs @@ -124,12 +124,12 @@ impl EnemyNPCState { /// 6. check if action points are available (yes) /// 7. run /// 8. shoot (notice that we don't have action points -/// here but we try anyway and move on the sequence) +/// here but we try anyway and move on the sequence) /// 9. check if action points are available (no) /// 10. exit the subprogram /// 11. rest and regain action points -/// (this action returns [Running] until fully rested -/// so control flow is returned to main loop) +/// (this action returns [Running] until fully rested +/// so control flow is returned to main loop) /// 12. kill the NPC /// 13. check if the NPC is dead (yes) /// 14. quit