behavior_machines 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +29 -0
- data/README.md +126 -0
- data/lib/behavior_machines/definition.rb +90 -0
- data/lib/behavior_machines/engine.rb +482 -0
- data/lib/behavior_machines/errors.rb +15 -0
- data/lib/behavior_machines/machine.rb +76 -0
- data/lib/behavior_machines/native_extension.rb +48 -0
- data/lib/behavior_machines/node.rb +95 -0
- data/lib/behavior_machines/tree.rb +95 -0
- data/lib/behavior_machines/version.rb +5 -0
- data/lib/behavior_machines.rb +50 -0
- metadata +133 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ec761ecd4fe6d6f47c9441607ef0df573a49ec611d96f113f46547e7872b219d
|
|
4
|
+
data.tar.gz: 16be482a97faa6d3807aa41cc66d4e1088a1e0732f11ad12cfcb53e70e0fdc32
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 446b75ee20966a1564d2669645018c06dab4a2ce535ae2f57a6bdb070d1ec628e99dd29812ca32168103da2f9cbcefb46311557d3631b718844fe49b8a230d79
|
|
7
|
+
data.tar.gz: 3a0e4f7c47e9b787c95d1ab4a2c9dbe71205f46bedc291153a698093affc6d5d874789187fba678b7595ae1efe05e020236c48131889d63210adfd77be506266
|
data/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Abdelkader Boudih
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# behavior_machines
|
|
2
|
+
|
|
3
|
+
Behavior trees with explicit halt semantics for Ruby, following the
|
|
4
|
+
behavior-tree pattern. Pure Ruby, with an optional native backend built on the
|
|
5
|
+
[behavior-machines](ext/behavior_machines_native/core) Rust crate.
|
|
6
|
+
|
|
7
|
+
Each tick advances as far as the leaves allow. Every running leaf the tree
|
|
8
|
+
abandons, because a parallel decided, a reactive guard flipped, a timeout
|
|
9
|
+
expired, or the tree was halted, gets `halt` called exactly once, in the same
|
|
10
|
+
call. Leaves can therefore own real resources and release them reliably.
|
|
11
|
+
|
|
12
|
+
Requires Ruby 4.0 or newer.
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
require 'behavior_machines'
|
|
16
|
+
|
|
17
|
+
BM = BehaviorMachines
|
|
18
|
+
|
|
19
|
+
class BatteryLow
|
|
20
|
+
def tick(robot, _now) = robot.battery < 20 ? :success : :failure
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Charge
|
|
24
|
+
def tick(robot, _now)
|
|
25
|
+
return :success if robot.battery == 100
|
|
26
|
+
|
|
27
|
+
robot.charging = true
|
|
28
|
+
:running
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def halt(robot) = robot.charging = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Patrol
|
|
35
|
+
def tick(_robot, _now) = :running
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
tree = BM::Tree.new(
|
|
39
|
+
BM.fallback(
|
|
40
|
+
BM.sequence(BM.leaf(BatteryLow.new), BM.leaf(Charge.new)),
|
|
41
|
+
BM.leaf(Patrol.new)
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
tree.tick(robot) # => :running, charging
|
|
46
|
+
tree.halt(robot) # Charge#halt runs exactly once
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Nodes
|
|
50
|
+
|
|
51
|
+
- **Leaves**: any object responding to `tick(ctx, now)`, returning
|
|
52
|
+
`:success`, `:failure`, or `:running`, and optionally `halt(ctx)`. Built-in:
|
|
53
|
+
`BM.sleep(seconds)`, `BM.always_success`, `BM.always_failure`.
|
|
54
|
+
- **Control**: `BM.sequence`, `BM.reactive_sequence`, `BM.fallback`,
|
|
55
|
+
`BM.reactive_fallback`, `BM.parallel(*children, success: :all, failure: 1)`,
|
|
56
|
+
`BM.if_then_else(condition, then_branch, otherwise = nil)`,
|
|
57
|
+
`BM.while_do_else(condition, body, otherwise = nil)`.
|
|
58
|
+
- **Decorators**, on any node: `inverter`, `force_success`, `force_failure`,
|
|
59
|
+
`timeout(seconds)`, `repeat(limit)`, `retry_until_successful(limit)`,
|
|
60
|
+
`keep_running_until_failure`. Limits are an Integer or `:unlimited`; looping
|
|
61
|
+
decorators finish at most one child run per tick.
|
|
62
|
+
|
|
63
|
+
Node definitions are immutable `Data` values; `BM::Tree.new` validates and
|
|
64
|
+
compiles them.
|
|
65
|
+
|
|
66
|
+
## Time
|
|
67
|
+
|
|
68
|
+
Durations are seconds. The clock is an Integer count of nanoseconds, passed as
|
|
69
|
+
`now:` and defaulting to `BehaviorMachines.now` (monotonic).
|
|
70
|
+
`tree.next_deadline` returns when a running `sleep` or `timeout` next needs a
|
|
71
|
+
tick, so a host can sleep until then.
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
|
|
75
|
+
If a leaf or observer raises during a tick, no more leaf code runs in that
|
|
76
|
+
tick: active leaves stay active and idle ones don't start. Every leaf still
|
|
77
|
+
active is then halted, without observer reports, and the first error
|
|
78
|
+
propagates. The raising leaf counts as finished. During a halt, every active
|
|
79
|
+
leaf is halted even if a leaf's `halt` or the observer raises, and the first
|
|
80
|
+
error propagates afterwards.
|
|
81
|
+
|
|
82
|
+
A leaf returning anything but the three statuses raises
|
|
83
|
+
`BehaviorMachines::InvalidStatusError`. A leaf calling back into the tree
|
|
84
|
+
that is ticking it raises `BehaviorMachines::ReentrantCallError`. Non-local
|
|
85
|
+
exits from a leaf, such as `throw` or `break`, are not supported.
|
|
86
|
+
|
|
87
|
+
Both backends follow these rules identically; the test suite compares them
|
|
88
|
+
side by side, including with raising leaves.
|
|
89
|
+
|
|
90
|
+
## Observers
|
|
91
|
+
|
|
92
|
+
`tree.tick(ctx, observer:)` reports `ticked(node_id, status)` and
|
|
93
|
+
`halted(node_id)` for every node, children before parents. Node ids number the
|
|
94
|
+
definition in pre-order.
|
|
95
|
+
|
|
96
|
+
## state_machines
|
|
97
|
+
|
|
98
|
+
`BehaviorMachines::Machine` has leaves for objects powered by the
|
|
99
|
+
[state_machines](https://github.com/state-machines/state_machines) gem:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
job_leaves = BM.sequence(
|
|
103
|
+
BM.leaf(BM::Machine::Fire.new(:job, :start)),
|
|
104
|
+
BM.leaf(BM::Machine::WaitFor.new(:job, success: :completed, failure: :failed, on_halt: :cancel))
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`InState` checks the state, `Fire` fires an event, and `WaitFor` waits for a
|
|
109
|
+
machine something else drives, firing `on_halt` if the tree abandons it.
|
|
110
|
+
|
|
111
|
+
## Native backend
|
|
112
|
+
|
|
113
|
+
The native backend runs the same trees on the Rust engine. It is opt-in:
|
|
114
|
+
|
|
115
|
+
```console
|
|
116
|
+
$ bundle exec rake compile
|
|
117
|
+
$ BEHAVIOR_MACHINES_NATIVE=1 ruby app.rb
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`BM::Tree.new(root, backend: :ruby | :native | :auto)` picks one explicitly.
|
|
121
|
+
Leaves stay Ruby, so the gain is limited to the tree walk itself. The test
|
|
122
|
+
suite runs against both backends (`bundle exec rake test:both`).
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
BSD-3-Clause. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorMachines
|
|
4
|
+
# Validates a node definition and lowers it to the plain nested-Array spec
|
|
5
|
+
# that both backends build from, so they always agree on shape and on the
|
|
6
|
+
# pre-order numbering observers see.
|
|
7
|
+
module Definition
|
|
8
|
+
# The clock is an unsigned 64-bit count of nanoseconds.
|
|
9
|
+
MAX_NANOS = (2**64) - 1
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def compile(node)
|
|
14
|
+
case node
|
|
15
|
+
when Node::Leaf then leaf(node.leaf)
|
|
16
|
+
when Node::AlwaysSuccess then %i[constant success]
|
|
17
|
+
when Node::AlwaysFailure then %i[constant failure]
|
|
18
|
+
when Node::Sleep then [:sleep, nanos(node.seconds, 'Sleep')]
|
|
19
|
+
when Node::Sequence then ordered(node.children, 'Sequence', :success, false)
|
|
20
|
+
when Node::ReactiveSequence then ordered(node.children, 'ReactiveSequence', :success, true)
|
|
21
|
+
when Node::Fallback then ordered(node.children, 'Fallback', :failure, false)
|
|
22
|
+
when Node::ReactiveFallback then ordered(node.children, 'ReactiveFallback', :failure, true)
|
|
23
|
+
when Node::Parallel then parallel(node)
|
|
24
|
+
when Node::IfThenElse then conditional(node.condition, node.then_branch, node.otherwise, false)
|
|
25
|
+
when Node::WhileDoElse then conditional(node.condition, node.body, node.otherwise, true)
|
|
26
|
+
when Node::Inverter then [:map, compile(node.child), :inverter]
|
|
27
|
+
when Node::ForceSuccess then [:map, compile(node.child), :force_success]
|
|
28
|
+
when Node::ForceFailure then [:map, compile(node.child), :force_failure]
|
|
29
|
+
when Node::Timeout then [:timeout, nanos(node.seconds, 'Timeout'), compile(node.child)]
|
|
30
|
+
when Node::Repeat then [:loop, compile(node.child), :success, limit(node.limit, 'Repeat')]
|
|
31
|
+
when Node::RetryUntilSuccessful
|
|
32
|
+
[:loop, compile(node.child), :failure, limit(node.limit, 'RetryUntilSuccessful')]
|
|
33
|
+
when Node::KeepRunningUntilFailure then [:loop, compile(node.child), :success, nil]
|
|
34
|
+
else raise BuildError, "#{node.inspect} is not a BehaviorMachines node"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def leaf(object)
|
|
39
|
+
raise BuildError, "leaf #{object.inspect} must respond to tick(ctx, now)" unless object.respond_to?(:tick)
|
|
40
|
+
|
|
41
|
+
[:leaf, object, object.respond_to?(:halt)]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ordered(children, name, advance_on, reactive)
|
|
45
|
+
[:ordered, children_of(children, name), advance_on, reactive]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parallel(node)
|
|
49
|
+
children = children_of(node.children, 'Parallel')
|
|
50
|
+
[:parallel, children, threshold(node.success, children.size), threshold(node.failure, children.size)]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def conditional(condition, branch, otherwise, reactive)
|
|
54
|
+
# Only nil means absent; anything else must be a node.
|
|
55
|
+
[:conditional, compile(condition), compile(branch), otherwise.nil? ? nil : compile(otherwise), reactive]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def children_of(children, name)
|
|
59
|
+
raise BuildError, "#{name} needs at least one child" if children.empty?
|
|
60
|
+
|
|
61
|
+
children.map { |child| compile(child) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def threshold(value, children)
|
|
65
|
+
return children if value == :all
|
|
66
|
+
raise BuildError, "Parallel threshold must be :all or an Integer, got #{value.inspect}" unless value.is_a?(Integer)
|
|
67
|
+
raise BuildError, "Parallel threshold #{value} must be between 1 and #{children}" unless value.between?(1, children)
|
|
68
|
+
|
|
69
|
+
value
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def nanos(seconds, name)
|
|
73
|
+
unless seconds.is_a?(Numeric) && seconds.real? && seconds.finite? && !seconds.negative?
|
|
74
|
+
raise BuildError, "#{name} needs a finite, non-negative number of seconds, got #{seconds.inspect}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
nanos = (seconds.to_r * 1_000_000_000).round
|
|
78
|
+
raise BuildError, "#{name} of #{seconds} seconds exceeds the 64-bit nanosecond clock" if nanos > MAX_NANOS
|
|
79
|
+
|
|
80
|
+
nanos
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def limit(value, name)
|
|
84
|
+
return nil if value == :unlimited
|
|
85
|
+
return value if value.is_a?(Integer) && value.between?(0, MAX_NANOS)
|
|
86
|
+
|
|
87
|
+
raise BuildError, "#{name} limit must be :unlimited or an Integer in 0..2**64-1, got #{value.inspect}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorMachines
|
|
4
|
+
# The pure-Ruby backend. It mirrors the Rust engine node for node; the same
|
|
5
|
+
# test suite runs against both backends.
|
|
6
|
+
module Engine
|
|
7
|
+
MAX_TIME = Definition::MAX_NANOS
|
|
8
|
+
|
|
9
|
+
# Discards observer reports.
|
|
10
|
+
module NullObserver
|
|
11
|
+
def self.ticked(_id, _status) = nil
|
|
12
|
+
def self.halted(_id) = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# One tick or halt. `errors` collects what leaves and observers raise; once
|
|
16
|
+
# it holds one, no more leaf ticks or observer reports run this call.
|
|
17
|
+
Run = Data.define(:ctx, :observer, :errors) do
|
|
18
|
+
def self.start(ctx, observer) = new(ctx, observer || NullObserver, [])
|
|
19
|
+
|
|
20
|
+
def failed? = !errors.empty?
|
|
21
|
+
|
|
22
|
+
def record(error)
|
|
23
|
+
errors << error
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def report_ticked(id, status) = report { observer.ticked(id, status) }
|
|
28
|
+
def report_halted(id) = report { observer.halted(id) }
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def report
|
|
33
|
+
return if failed?
|
|
34
|
+
|
|
35
|
+
yield
|
|
36
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, raised after cleanup
|
|
37
|
+
record(e)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A deadline past the end of the clock never fires.
|
|
42
|
+
def self.deadline_after(now, nanos)
|
|
43
|
+
deadline = now + nanos
|
|
44
|
+
deadline > MAX_TIME ? nil : deadline
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.reached?(deadline, now) = !deadline.nil? && now >= deadline
|
|
48
|
+
|
|
49
|
+
# Compiled tree plus its execution state.
|
|
50
|
+
class Tree
|
|
51
|
+
def initialize(spec)
|
|
52
|
+
@root = Builder.new.build(spec)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Once a leaf or observer raises, the rest of the tick runs no Ruby: an
|
|
56
|
+
# active leaf stays active and an idle one does not start. Then every
|
|
57
|
+
# leaf still active is halted, without observer reports, and the first
|
|
58
|
+
# error propagates. Errors raised during that cleanup are dropped.
|
|
59
|
+
def tick(ctx, now, observer)
|
|
60
|
+
run = Run.start(ctx, observer)
|
|
61
|
+
status = @root.tick(run, now)
|
|
62
|
+
return status unless run.failed?
|
|
63
|
+
|
|
64
|
+
@root.halt(Run.start(ctx, nil))
|
|
65
|
+
raise run.errors.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Every active leaf is halted even if one, or the observer, raises; the
|
|
69
|
+
# first error propagates afterwards.
|
|
70
|
+
def halt(ctx, observer)
|
|
71
|
+
run = Run.start(ctx, observer)
|
|
72
|
+
@root.halt(run)
|
|
73
|
+
raise run.errors.first if run.failed?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def running? = @root.running?
|
|
77
|
+
def next_deadline = @root.next_deadline
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Builds slots from a spec, numbering nodes in pre-order.
|
|
81
|
+
class Builder
|
|
82
|
+
def initialize
|
|
83
|
+
@next_id = 0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build(spec)
|
|
87
|
+
id = @next_id
|
|
88
|
+
@next_id += 1
|
|
89
|
+
kind, *args = spec
|
|
90
|
+
case kind
|
|
91
|
+
when :leaf then LeafSlot.new(id, *args)
|
|
92
|
+
when :constant then ConstantSlot.new(id, *args)
|
|
93
|
+
when :sleep then SleepSlot.new(id, *args)
|
|
94
|
+
when :ordered then ordered(id, *args)
|
|
95
|
+
when :parallel then parallel(id, *args)
|
|
96
|
+
when :conditional then conditional(id, *args)
|
|
97
|
+
when :map then MapSlot.new(id, build(args[0]), args[1])
|
|
98
|
+
when :timeout then TimeoutSlot.new(id, args[0], build(args[1]))
|
|
99
|
+
when :loop then LoopSlot.new(id, build(args[0]), args[1], args[2])
|
|
100
|
+
else raise ArgumentError, "unknown spec #{kind.inspect}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def ordered(id, children, advance_on, reactive)
|
|
107
|
+
(reactive ? ReactiveSlot : OrderedSlot).new(id, children.map { |child| build(child) }, advance_on)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parallel(id, children, success, failure)
|
|
111
|
+
ParallelSlot.new(id, children.map { |child| build(child) }, success, failure)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def conditional(id, condition, branch, otherwise, reactive)
|
|
115
|
+
condition = build(condition)
|
|
116
|
+
branch = build(branch)
|
|
117
|
+
otherwise &&= build(otherwise)
|
|
118
|
+
(reactive ? WhileDoElseSlot : IfThenElseSlot).new(id, condition, branch, otherwise)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One compiled node. `@running` is set while its last tick returned
|
|
123
|
+
# :running and it has not been halted since; only running slots halt.
|
|
124
|
+
class Slot
|
|
125
|
+
def initialize(id)
|
|
126
|
+
@id = id
|
|
127
|
+
@running = false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def running? = @running
|
|
131
|
+
|
|
132
|
+
def tick(run, now)
|
|
133
|
+
status = step(run, now, !@running)
|
|
134
|
+
@running = status == :running
|
|
135
|
+
run.report_ticked(@id, status)
|
|
136
|
+
status
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def halt(run)
|
|
140
|
+
return unless @running
|
|
141
|
+
|
|
142
|
+
@running = false
|
|
143
|
+
stop(run)
|
|
144
|
+
run.report_halted(@id)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def next_deadline = @running ? running_deadline : nil
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def stop(_run) = nil
|
|
152
|
+
def running_deadline = nil
|
|
153
|
+
def halt_all(slots, run) = slots.each { |slot| slot.halt(run) }
|
|
154
|
+
def earliest(deadlines) = deadlines.compact.min
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A user leaf.
|
|
158
|
+
class LeafSlot < Slot
|
|
159
|
+
def initialize(id, leaf, halts)
|
|
160
|
+
super(id)
|
|
161
|
+
@leaf = leaf
|
|
162
|
+
@halts = halts
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
# After an error no more leaf code runs this call: an active leaf stays
|
|
168
|
+
# active (so cleanup halts it) and an idle one does not start. A leaf
|
|
169
|
+
# that raises counts as finished.
|
|
170
|
+
def step(run, now, starting)
|
|
171
|
+
return starting ? :failure : :running if run.failed?
|
|
172
|
+
|
|
173
|
+
BehaviorMachines.check_status(@leaf.tick(run.ctx, now), @leaf)
|
|
174
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, raised after cleanup
|
|
175
|
+
run.record(e)
|
|
176
|
+
:failure
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def stop(run)
|
|
180
|
+
@leaf.halt(run.ctx) if @halts
|
|
181
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- recorded; the halt continues
|
|
182
|
+
run.record(e)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# AlwaysSuccess and AlwaysFailure.
|
|
187
|
+
class ConstantSlot < Slot
|
|
188
|
+
def initialize(id, status)
|
|
189
|
+
super(id)
|
|
190
|
+
@status = status
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def step(_run, _now, _starting) = @status
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
class SleepSlot < Slot
|
|
199
|
+
def initialize(id, nanos)
|
|
200
|
+
super(id)
|
|
201
|
+
@nanos = nanos
|
|
202
|
+
@deadline = nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def step(_run, now, starting)
|
|
208
|
+
@deadline = Engine.deadline_after(now, @nanos) if starting
|
|
209
|
+
Engine.reached?(@deadline, now) ? :success : :running
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def running_deadline = @deadline
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Sequence and Fallback with memory: resume the running child.
|
|
216
|
+
class OrderedSlot < Slot
|
|
217
|
+
def initialize(id, children, advance_on)
|
|
218
|
+
super(id)
|
|
219
|
+
@children = children
|
|
220
|
+
@advance_on = advance_on
|
|
221
|
+
@cursor = 0
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def step(run, now, _starting)
|
|
227
|
+
while (child = @children[@cursor])
|
|
228
|
+
status = child.tick(run, now)
|
|
229
|
+
return :running if status == :running
|
|
230
|
+
return finish(status) unless status == @advance_on
|
|
231
|
+
|
|
232
|
+
@cursor += 1
|
|
233
|
+
end
|
|
234
|
+
finish(@advance_on)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def finish(status)
|
|
238
|
+
@cursor = 0
|
|
239
|
+
status
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def stop(run)
|
|
243
|
+
halt_all(@children, run)
|
|
244
|
+
@cursor = 0
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def running_deadline = earliest(@children.map(&:next_deadline))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ReactiveSequence and ReactiveFallback: re-walk from the first child.
|
|
251
|
+
class ReactiveSlot < Slot
|
|
252
|
+
def initialize(id, children, advance_on)
|
|
253
|
+
super(id)
|
|
254
|
+
@children = children
|
|
255
|
+
@advance_on = advance_on
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
def step(run, now, _starting)
|
|
261
|
+
@children.each_with_index do |child, index|
|
|
262
|
+
status = child.tick(run, now)
|
|
263
|
+
next if status == @advance_on
|
|
264
|
+
|
|
265
|
+
halt_all(@children.drop(index + 1), run)
|
|
266
|
+
return status
|
|
267
|
+
end
|
|
268
|
+
@advance_on
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def stop(run) = halt_all(@children, run)
|
|
272
|
+
def running_deadline = earliest(@children.map(&:next_deadline))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
class ParallelSlot < Slot
|
|
276
|
+
def initialize(id, children, success, failure)
|
|
277
|
+
super(id)
|
|
278
|
+
@children = children
|
|
279
|
+
@success = success
|
|
280
|
+
@failure = failure
|
|
281
|
+
@outcomes = Array.new(children.size)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
def step(run, now, _starting)
|
|
287
|
+
decided = decide(run, now)
|
|
288
|
+
return :running unless decided
|
|
289
|
+
|
|
290
|
+
halt_all(@children, run)
|
|
291
|
+
@outcomes.fill(nil)
|
|
292
|
+
decided
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Children decide in order, so the leftmost wins ties.
|
|
296
|
+
def decide(run, now)
|
|
297
|
+
succeeded = @outcomes.count(:success)
|
|
298
|
+
failed = @outcomes.count(:failure)
|
|
299
|
+
@children.each_with_index do |child, index|
|
|
300
|
+
next if @outcomes[index]
|
|
301
|
+
|
|
302
|
+
status = child.tick(run, now)
|
|
303
|
+
next if status == :running
|
|
304
|
+
|
|
305
|
+
@outcomes[index] = status
|
|
306
|
+
status == :success ? succeeded += 1 : failed += 1
|
|
307
|
+
return :success if succeeded >= @success
|
|
308
|
+
return :failure if failed >= @failure || @children.size - failed < @success
|
|
309
|
+
end
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def stop(run)
|
|
314
|
+
halt_all(@children, run)
|
|
315
|
+
@outcomes.fill(nil)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def running_deadline = earliest(@children.map(&:next_deadline))
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Shared by IfThenElse and WhileDoElse.
|
|
322
|
+
class ConditionalSlot < Slot
|
|
323
|
+
def initialize(id, condition, branch, otherwise)
|
|
324
|
+
super(id)
|
|
325
|
+
@condition = condition
|
|
326
|
+
@branch = branch
|
|
327
|
+
@otherwise = otherwise
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
def stop(run)
|
|
333
|
+
[@condition, @branch, @otherwise].compact.each { |slot| slot.halt(run) }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def running_deadline = earliest([@condition, @branch, @otherwise].compact.map(&:next_deadline))
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
class IfThenElseSlot < ConditionalSlot
|
|
340
|
+
def initialize(...)
|
|
341
|
+
super
|
|
342
|
+
@selected = nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
private
|
|
346
|
+
|
|
347
|
+
def step(run, now, _starting)
|
|
348
|
+
unless @selected
|
|
349
|
+
case @condition.tick(run, now)
|
|
350
|
+
when :running then return :running
|
|
351
|
+
when :success then @selected = :branch
|
|
352
|
+
else @selected = :otherwise
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
chosen = @selected == :branch ? @branch : @otherwise
|
|
356
|
+
return finish(:failure) unless chosen
|
|
357
|
+
|
|
358
|
+
status = chosen.tick(run, now)
|
|
359
|
+
status == :running ? status : finish(status)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def finish(status)
|
|
363
|
+
@selected = nil
|
|
364
|
+
status
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def stop(run)
|
|
368
|
+
super
|
|
369
|
+
@selected = nil
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# While the condition runs the branches are paused: neither ticked nor
|
|
374
|
+
# halted. Otherwise the unselected branch is halted before the other ticks.
|
|
375
|
+
class WhileDoElseSlot < ConditionalSlot
|
|
376
|
+
private
|
|
377
|
+
|
|
378
|
+
def step(run, now, _starting)
|
|
379
|
+
case @condition.tick(run, now)
|
|
380
|
+
when :running then :running
|
|
381
|
+
when :success
|
|
382
|
+
@otherwise&.halt(run)
|
|
383
|
+
@branch.tick(run, now)
|
|
384
|
+
else
|
|
385
|
+
@branch.halt(run)
|
|
386
|
+
@otherwise ? @otherwise.tick(run, now) : :failure
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# A paused branch's timers cannot act until the condition finishes.
|
|
391
|
+
def running_deadline = @condition.running? ? @condition.next_deadline : super
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Inverter, ForceSuccess, and ForceFailure.
|
|
395
|
+
class MapSlot < Slot
|
|
396
|
+
def initialize(id, child, map)
|
|
397
|
+
super(id)
|
|
398
|
+
@child = child
|
|
399
|
+
@map = map
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
private
|
|
403
|
+
|
|
404
|
+
def step(run, now, _starting)
|
|
405
|
+
status = @child.tick(run, now)
|
|
406
|
+
return status if status == :running
|
|
407
|
+
|
|
408
|
+
case @map
|
|
409
|
+
when :inverter then status == :success ? :failure : :success
|
|
410
|
+
when :force_success then :success
|
|
411
|
+
else :failure
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def stop(run) = @child.halt(run)
|
|
416
|
+
def running_deadline = @child.next_deadline
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The child only ticks strictly before the deadline.
|
|
420
|
+
class TimeoutSlot < Slot
|
|
421
|
+
def initialize(id, nanos, child)
|
|
422
|
+
super(id)
|
|
423
|
+
@nanos = nanos
|
|
424
|
+
@child = child
|
|
425
|
+
@deadline = nil
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
private
|
|
429
|
+
|
|
430
|
+
def step(run, now, starting)
|
|
431
|
+
@deadline = Engine.deadline_after(now, @nanos) if starting
|
|
432
|
+
if Engine.reached?(@deadline, now)
|
|
433
|
+
@child.halt(run)
|
|
434
|
+
return :failure
|
|
435
|
+
end
|
|
436
|
+
@child.tick(run, now)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def stop(run) = @child.halt(run)
|
|
440
|
+
def running_deadline = earliest([@deadline, @child.next_deadline])
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Repeat, RetryUntilSuccessful, and KeepRunningUntilFailure: at most one
|
|
444
|
+
# finished child run per tick.
|
|
445
|
+
class LoopSlot < Slot
|
|
446
|
+
def initialize(id, child, again_on, limit)
|
|
447
|
+
super(id)
|
|
448
|
+
@child = child
|
|
449
|
+
@again_on = again_on
|
|
450
|
+
@limit = limit
|
|
451
|
+
@runs = 0
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
private
|
|
455
|
+
|
|
456
|
+
def step(run, now, _starting)
|
|
457
|
+
return @again_on if @limit&.zero?
|
|
458
|
+
|
|
459
|
+
status = @child.tick(run, now)
|
|
460
|
+
return status if status == :running
|
|
461
|
+
return finish(status) unless status == @again_on
|
|
462
|
+
return :running if @limit.nil?
|
|
463
|
+
return finish(status) if @runs + 1 == @limit
|
|
464
|
+
|
|
465
|
+
@runs += 1
|
|
466
|
+
:running
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def finish(status)
|
|
470
|
+
@runs = 0
|
|
471
|
+
status
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def stop(run)
|
|
475
|
+
@child.halt(run)
|
|
476
|
+
@runs = 0
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def running_deadline = @child.next_deadline
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorMachines
|
|
4
|
+
# Base class for errors raised by this library.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# A node definition cannot be compiled into a tree.
|
|
8
|
+
class BuildError < Error; end
|
|
9
|
+
|
|
10
|
+
# A leaf's tick returned something other than :success, :failure, or :running.
|
|
11
|
+
class InvalidStatusError < Error; end
|
|
12
|
+
|
|
13
|
+
# A leaf called Tree#tick or Tree#halt on the tree that is ticking it.
|
|
14
|
+
class ReentrantCallError < Error; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorMachines
|
|
4
|
+
# Leaves that drive objects powered by the state_machines gem, through the
|
|
5
|
+
# helpers it generates for every machine: `<machine>_name` and
|
|
6
|
+
# `fire_<machine>_event`.
|
|
7
|
+
#
|
|
8
|
+
# Each leaf finds its object in the tree context with `access`: a Symbol
|
|
9
|
+
# naming a method on the context, or anything responding to call(ctx).
|
|
10
|
+
module Machine
|
|
11
|
+
def self.accessor(access)
|
|
12
|
+
case access
|
|
13
|
+
when Symbol, String then ->(ctx) { ctx.public_send(access) }
|
|
14
|
+
else
|
|
15
|
+
raise ArgumentError, "access must be a Symbol or respond to call, got #{access.inspect}" unless access.respond_to?(:call)
|
|
16
|
+
|
|
17
|
+
access
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Succeeds while the machine is in one of `states`, fails otherwise.
|
|
22
|
+
class InState
|
|
23
|
+
def initialize(access, *states, machine: :state)
|
|
24
|
+
raise ArgumentError, 'InState needs at least one state' if states.empty?
|
|
25
|
+
|
|
26
|
+
@access = Machine.accessor(access)
|
|
27
|
+
@states = states.map(&:to_sym).freeze
|
|
28
|
+
@reader = :"#{machine}_name"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def tick(ctx, _now) = @states.include?(@access.call(ctx).public_send(@reader)) ? :success : :failure
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Fires `event` each tick: succeeds if the machine accepts it, fails if it
|
|
35
|
+
# is rejected.
|
|
36
|
+
class Fire
|
|
37
|
+
def initialize(access, event, machine: :state)
|
|
38
|
+
@access = Machine.accessor(access)
|
|
39
|
+
@event = event.to_sym
|
|
40
|
+
@fire = :"fire_#{machine}_event"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def tick(ctx, _now) = @access.call(ctx).public_send(@fire, @event) ? :success : :failure
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Waits on a machine something else drives: succeeds in a `success` state,
|
|
47
|
+
# fails in a `failure` state, runs otherwise. If the tree abandons it,
|
|
48
|
+
# fires `on_halt` (typically :cancel).
|
|
49
|
+
class WaitFor
|
|
50
|
+
def initialize(access, success:, failure: [], on_halt: nil, machine: :state)
|
|
51
|
+
@access = Machine.accessor(access)
|
|
52
|
+
@success = Array(success).map(&:to_sym).freeze
|
|
53
|
+
@failure = Array(failure).map(&:to_sym).freeze
|
|
54
|
+
@on_halt = on_halt&.to_sym
|
|
55
|
+
@reader = :"#{machine}_name"
|
|
56
|
+
@fire = :"fire_#{machine}_event"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def tick(ctx, _now)
|
|
60
|
+
state = @access.call(ctx).public_send(@reader)
|
|
61
|
+
return :success if @success.include?(state)
|
|
62
|
+
return :failure if @failure.include?(state)
|
|
63
|
+
|
|
64
|
+
:running
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A rejected event means the machine already left the states that accept
|
|
68
|
+
# it, e.g. a job that completed after the last tick. The tree abandons
|
|
69
|
+
# the leaf either way, so the result is deliberately ignored.
|
|
70
|
+
def halt(ctx)
|
|
71
|
+
@access.call(ctx).public_send(@fire, @on_halt) if @on_halt
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
|
|
5
|
+
module BehaviorMachines
|
|
6
|
+
# Loads the optional native backend. It is opt-in: set
|
|
7
|
+
# BEHAVIOR_MACHINES_NATIVE=1 before requiring behavior_machines.
|
|
8
|
+
module NativeExtension
|
|
9
|
+
class << self
|
|
10
|
+
# Why the last load attempt failed, if it did.
|
|
11
|
+
attr_reader :error
|
|
12
|
+
|
|
13
|
+
def load!
|
|
14
|
+
return @loaded if defined?(@loaded)
|
|
15
|
+
return @loaded = false unless ENV['BEHAVIOR_MACHINES_NATIVE'] == '1'
|
|
16
|
+
|
|
17
|
+
errors = []
|
|
18
|
+
candidates.each do |path|
|
|
19
|
+
require path
|
|
20
|
+
return @loaded = true
|
|
21
|
+
rescue LoadError => e
|
|
22
|
+
errors << "#{path}: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
@error = errors.join(' | ')
|
|
25
|
+
@loaded = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def loaded? = load!
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Prefer a build matching this Ruby version and platform.
|
|
33
|
+
def candidates
|
|
34
|
+
dlext = RbConfig::CONFIG['DLEXT']
|
|
35
|
+
root = File.expand_path('..', __dir__)
|
|
36
|
+
builds = Dir.glob(File.join(root, 'behavior_machines_native', '**', "behavior_machines_native.#{dlext}"))
|
|
37
|
+
ranked = builds.sort_by do |path|
|
|
38
|
+
score = 0
|
|
39
|
+
score -= 2 if path.include?(RbConfig::CONFIG['ruby_version'])
|
|
40
|
+
score -= 1 if path.include?(RbConfig::CONFIG['arch'])
|
|
41
|
+
[score, path]
|
|
42
|
+
end
|
|
43
|
+
paths = ranked.map { |path| path.delete_prefix("#{root}/").delete_suffix(".#{dlext}") }
|
|
44
|
+
(paths + ['behavior_machines_native/behavior_machines_native']).uniq
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorMachines
|
|
4
|
+
# Immutable node definitions. Build them with the module functions on
|
|
5
|
+
# BehaviorMachines (BehaviorMachines.sequence, .parallel, ...) and the
|
|
6
|
+
# decorator methods every node responds to, then compile with Tree.new.
|
|
7
|
+
#
|
|
8
|
+
# Names and semantics follow the behavior-tree pattern, matching the
|
|
9
|
+
# behavior-machines Rust crate.
|
|
10
|
+
module Node
|
|
11
|
+
# Decorators available on every node.
|
|
12
|
+
module Decorators
|
|
13
|
+
def inverter = Inverter.new(child: self)
|
|
14
|
+
def force_success = ForceSuccess.new(child: self)
|
|
15
|
+
def force_failure = ForceFailure.new(child: self)
|
|
16
|
+
def timeout(seconds) = Timeout.new(seconds:, child: self)
|
|
17
|
+
def repeat(limit) = Repeat.new(limit:, child: self)
|
|
18
|
+
def retry_until_successful(limit) = RetryUntilSuccessful.new(limit:, child: self)
|
|
19
|
+
def keep_running_until_failure = KeepRunningUntilFailure.new(child: self)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.define(*members) = Data.define(*members) { include Decorators }
|
|
23
|
+
private_class_method :define
|
|
24
|
+
|
|
25
|
+
Leaf = define(:leaf)
|
|
26
|
+
AlwaysSuccess = define
|
|
27
|
+
AlwaysFailure = define
|
|
28
|
+
Sleep = define(:seconds)
|
|
29
|
+
Sequence = define(:children)
|
|
30
|
+
ReactiveSequence = define(:children)
|
|
31
|
+
Fallback = define(:children)
|
|
32
|
+
ReactiveFallback = define(:children)
|
|
33
|
+
Parallel = define(:children, :success, :failure)
|
|
34
|
+
IfThenElse = define(:condition, :then_branch, :otherwise)
|
|
35
|
+
WhileDoElse = define(:condition, :body, :otherwise)
|
|
36
|
+
Inverter = define(:child)
|
|
37
|
+
ForceSuccess = define(:child)
|
|
38
|
+
ForceFailure = define(:child)
|
|
39
|
+
Timeout = define(:seconds, :child)
|
|
40
|
+
Repeat = define(:limit, :child)
|
|
41
|
+
RetryUntilSuccessful = define(:limit, :child)
|
|
42
|
+
KeepRunningUntilFailure = define(:child)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# A condition check or action: any object responding to tick(ctx, now)
|
|
47
|
+
# and, optionally, halt(ctx).
|
|
48
|
+
def leaf(leaf) = Node::Leaf.new(leaf:)
|
|
49
|
+
|
|
50
|
+
# Succeeds immediately.
|
|
51
|
+
def always_success = Node::AlwaysSuccess.new
|
|
52
|
+
|
|
53
|
+
# Fails immediately.
|
|
54
|
+
def always_failure = Node::AlwaysFailure.new
|
|
55
|
+
|
|
56
|
+
# Runs until `seconds` have passed since it started, then succeeds.
|
|
57
|
+
def sleep(seconds) = Node::Sleep.new(seconds:)
|
|
58
|
+
|
|
59
|
+
# Ticks children in order; fails on the first failure, succeeds once all
|
|
60
|
+
# succeed. A running child is resumed on the next tick.
|
|
61
|
+
def sequence(*children) = Node::Sequence.new(children: children.freeze)
|
|
62
|
+
|
|
63
|
+
# A sequence that restarts from its first child every tick, halting a
|
|
64
|
+
# running later child once an earlier one stops succeeding.
|
|
65
|
+
def reactive_sequence(*children) = Node::ReactiveSequence.new(children: children.freeze)
|
|
66
|
+
|
|
67
|
+
# Ticks children in order; succeeds on the first success, fails once all
|
|
68
|
+
# fail. A running child is resumed on the next tick.
|
|
69
|
+
def fallback(*children) = Node::Fallback.new(children: children.freeze)
|
|
70
|
+
|
|
71
|
+
# A fallback that restarts from its first child every tick, so a
|
|
72
|
+
# higher-priority child preempts (halts) a running lower-priority one.
|
|
73
|
+
def reactive_fallback(*children) = Node::ReactiveFallback.new(children: children.freeze)
|
|
74
|
+
|
|
75
|
+
# Ticks every unfinished child each tick. Succeeds once `success` children
|
|
76
|
+
# succeeded; fails once `failure` failed or success became unreachable.
|
|
77
|
+
# Thresholds are :all or an Integer; by default every child must succeed
|
|
78
|
+
# and one failure fails the node.
|
|
79
|
+
def parallel(*children, success: :all, failure: 1)
|
|
80
|
+
Node::Parallel.new(children: children.freeze, success:, failure:)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Ticks `condition` until it finishes, then runs `then_branch` or
|
|
84
|
+
# `otherwise`. Without `otherwise`, a failed condition fails the node.
|
|
85
|
+
def if_then_else(condition, then_branch, otherwise = nil)
|
|
86
|
+
Node::IfThenElse.new(condition:, then_branch:, otherwise:)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The reactive if_then_else: re-checks `condition` every tick, pausing the
|
|
90
|
+
# branches while it runs and halting the unselected branch when it flips.
|
|
91
|
+
def while_do_else(condition, body, otherwise = nil)
|
|
92
|
+
Node::WhileDoElse.new(condition:, body:, otherwise:)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorMachines
|
|
4
|
+
# A compiled behavior tree and its execution state.
|
|
5
|
+
#
|
|
6
|
+
# Each tick advances as far as the leaves allow; looping decorators finish
|
|
7
|
+
# at most one child run per tick. When the tree abandons a running node,
|
|
8
|
+
# every active leaf under it is halted exactly once before the call returns.
|
|
9
|
+
#
|
|
10
|
+
# Leaves are objects responding to `tick(ctx, now)`, returning :success,
|
|
11
|
+
# :failure, or :running, and optionally `halt(ctx)`.
|
|
12
|
+
#
|
|
13
|
+
# If a leaf or observer raises during a tick, the rest of that tick runs no
|
|
14
|
+
# leaf code (active leaves stay active, idle ones do not start), every leaf
|
|
15
|
+
# still active is then halted without observer reports, and the first error
|
|
16
|
+
# propagates. The raising leaf counts as finished. During a halt, every
|
|
17
|
+
# active leaf is halted even if a leaf's halt or the observer raises; the
|
|
18
|
+
# first error propagates afterwards. Non-local exits from a leaf, such as
|
|
19
|
+
# `throw` or `break`, are not supported.
|
|
20
|
+
#
|
|
21
|
+
# Time is an Integer count of nanoseconds on a monotonic clock, passed as
|
|
22
|
+
# `now:` (default BehaviorMachines.now). Durations in node definitions are
|
|
23
|
+
# seconds.
|
|
24
|
+
class Tree
|
|
25
|
+
attr_reader :backend
|
|
26
|
+
|
|
27
|
+
# Compiles `root`. `backend:` is :ruby, :native, or :auto (native when the
|
|
28
|
+
# extension is loaded, see BehaviorMachines.native_available?).
|
|
29
|
+
def initialize(root, backend: :auto)
|
|
30
|
+
spec = Definition.compile(root)
|
|
31
|
+
@backend = BehaviorMachines.resolve_backend(backend)
|
|
32
|
+
@engine = @backend == :native ? BehaviorMachinesNative::Tree.new(spec) : Engine::Tree.new(spec)
|
|
33
|
+
@busy = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Ticks once and returns the root's status. `observer` may respond to
|
|
37
|
+
# `ticked(node_id, status)` and `halted(node_id)`; node ids number the
|
|
38
|
+
# definition in pre-order, and children report before their parent.
|
|
39
|
+
def tick(ctx = nil, now: BehaviorMachines.now, observer: nil)
|
|
40
|
+
now = BehaviorMachines.check_time(now)
|
|
41
|
+
exclusive { @engine.tick(ctx, now, Observers.wrap(observer)) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Halts every active leaf and resets the tree. Does nothing if not running.
|
|
45
|
+
def halt(ctx = nil, observer: nil)
|
|
46
|
+
exclusive { @engine.halt(ctx, Observers.wrap(observer)) }
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether the last tick returned :running and the tree was not halted since.
|
|
51
|
+
def running?
|
|
52
|
+
check_idle
|
|
53
|
+
@engine.running?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The earliest time, in nanoseconds, a running Sleep or Timeout will fire.
|
|
57
|
+
def next_deadline
|
|
58
|
+
check_idle
|
|
59
|
+
@engine.next_deadline
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def check_idle
|
|
65
|
+
raise ReentrantCallError, 'a leaf called back into the tree that is ticking it' if @busy
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def exclusive
|
|
69
|
+
check_idle
|
|
70
|
+
|
|
71
|
+
begin
|
|
72
|
+
@busy = true
|
|
73
|
+
yield
|
|
74
|
+
ensure
|
|
75
|
+
@busy = false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Normalizes user observers so engines can call both hooks unconditionally.
|
|
81
|
+
module Observers
|
|
82
|
+
# Forwards only the hooks the wrapped observer implements.
|
|
83
|
+
Partial = Data.define(:target) do
|
|
84
|
+
def ticked(id, status) = (target.ticked(id, status) if target.respond_to?(:ticked))
|
|
85
|
+
def halted(id) = (target.halted(id) if target.respond_to?(:halted))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.wrap(observer)
|
|
89
|
+
return nil if observer.nil?
|
|
90
|
+
return observer if observer.respond_to?(:ticked) && observer.respond_to?(:halted)
|
|
91
|
+
|
|
92
|
+
Partial.new(observer)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'behavior_machines/version'
|
|
4
|
+
require_relative 'behavior_machines/errors'
|
|
5
|
+
require_relative 'behavior_machines/node'
|
|
6
|
+
require_relative 'behavior_machines/definition'
|
|
7
|
+
require_relative 'behavior_machines/engine'
|
|
8
|
+
require_relative 'behavior_machines/tree'
|
|
9
|
+
require_relative 'behavior_machines/machine'
|
|
10
|
+
require_relative 'behavior_machines/native_extension'
|
|
11
|
+
|
|
12
|
+
# Behavior trees with explicit halt semantics, following the behavior-tree
|
|
13
|
+
# pattern. Pure Ruby, with an optional native backend built on the behavior-machines Rust crate.
|
|
14
|
+
module BehaviorMachines
|
|
15
|
+
STATUSES = %i[success failure running].freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# The current monotonic time in nanoseconds.
|
|
19
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
20
|
+
|
|
21
|
+
# Whether the native backend is loaded (opt-in, see NativeExtension).
|
|
22
|
+
def native_available? = NativeExtension.loaded?
|
|
23
|
+
|
|
24
|
+
def resolve_backend(backend)
|
|
25
|
+
case backend
|
|
26
|
+
when :ruby then :ruby
|
|
27
|
+
when :auto then native_available? ? :native : :ruby
|
|
28
|
+
when :native
|
|
29
|
+
return :native if native_available?
|
|
30
|
+
|
|
31
|
+
raise Error, "native backend not loaded (set BEHAVIOR_MACHINES_NATIVE=1#{"; #{NativeExtension.error}" if NativeExtension.error})"
|
|
32
|
+
else raise ArgumentError, "backend must be :ruby, :native, or :auto, got #{backend.inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def check_status(status, leaf)
|
|
37
|
+
return status if STATUSES.include?(status)
|
|
38
|
+
|
|
39
|
+
raise InvalidStatusError, "#{leaf.inspect} returned #{status.inspect}; expected :success, :failure, or :running"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def check_time(now)
|
|
43
|
+
return now if now.is_a?(Integer) && now.between?(0, Definition::MAX_NANOS)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "now must be an Integer number of nanoseconds in 0..2**64-1, got #{now.inspect}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
BehaviorMachines::NativeExtension.load!
|
metadata
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: behavior_machines
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Abdelkader Boudih
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: state_machines
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.202'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.202'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '6.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '6.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake-compiler
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.3'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.3'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rb_sys
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.9'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.9'
|
|
82
|
+
description: |
|
|
83
|
+
BehaviorMachines runs behavior trees whose nodes follow the behavior-tree pattern: Sequence,
|
|
84
|
+
Fallback, their reactive variants, thresholded Parallel,
|
|
85
|
+
IfThenElse, WhileDoElse, and the usual decorators. Every running leaf the tree abandons is halted
|
|
86
|
+
exactly once, in the same call, so leaves can own real resources. Pure Ruby, with an optional
|
|
87
|
+
native backend built on the behavior-machines Rust crate, and leaves for state_machines objects.
|
|
88
|
+
email:
|
|
89
|
+
- terminale@gmail.com
|
|
90
|
+
executables: []
|
|
91
|
+
extensions: []
|
|
92
|
+
extra_rdoc_files: []
|
|
93
|
+
files:
|
|
94
|
+
- LICENSE
|
|
95
|
+
- README.md
|
|
96
|
+
- lib/behavior_machines.rb
|
|
97
|
+
- lib/behavior_machines/definition.rb
|
|
98
|
+
- lib/behavior_machines/engine.rb
|
|
99
|
+
- lib/behavior_machines/errors.rb
|
|
100
|
+
- lib/behavior_machines/machine.rb
|
|
101
|
+
- lib/behavior_machines/native_extension.rb
|
|
102
|
+
- lib/behavior_machines/node.rb
|
|
103
|
+
- lib/behavior_machines/tree.rb
|
|
104
|
+
- lib/behavior_machines/version.rb
|
|
105
|
+
homepage: https://github.com/seuros/behavior-machines
|
|
106
|
+
licenses:
|
|
107
|
+
- BSD-3-Clause
|
|
108
|
+
metadata:
|
|
109
|
+
homepage_uri: https://github.com/seuros/behavior-machines
|
|
110
|
+
source_code_uri: https://github.com/seuros/behavior-machines
|
|
111
|
+
bug_tracker_uri: https://github.com/seuros/behavior-machines/issues
|
|
112
|
+
rubygems_mfa_required: 'true'
|
|
113
|
+
cargo_crate_name: behavior_machines_native
|
|
114
|
+
cargo_manifest_path: ext/behavior_machines_native/ffi/Cargo.toml
|
|
115
|
+
rdoc_options: []
|
|
116
|
+
require_paths:
|
|
117
|
+
- lib
|
|
118
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - ">="
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: 4.0.0
|
|
123
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
124
|
+
requirements:
|
|
125
|
+
- - ">="
|
|
126
|
+
- !ruby/object:Gem::Version
|
|
127
|
+
version: '0'
|
|
128
|
+
requirements: []
|
|
129
|
+
rubygems_version: 4.0.20
|
|
130
|
+
specification_version: 4
|
|
131
|
+
summary: Behavior trees with explicit halt semantics, following the behavior-tree
|
|
132
|
+
pattern
|
|
133
|
+
test_files: []
|