feat(bt): Rename repeat to cycle and refactor runner
Rework task execution to be synchronous within a single tick. Scheduling boundaries are now defined by `cycle` nodes, which replace the previous `repeat` decorator. All tasks are executed immediately when scheduled, and parent/child propagation is handled inline.
This commit is contained in:
parent
1253bf82d2
commit
204b9100e6
6
USAGE.md
6
USAGE.md
|
|
@ -397,7 +397,7 @@ Behaviour trees control game flow by composing tasks into a tree. Each node in t
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { defineComponent, entity } from "ecs-observable";
|
import { defineComponent, entity } from "ecs-observable";
|
||||||
import { buildTree, Cancel, leaf, parallel, repeat, sequential } from "ecs-observable/bt";
|
import { buildTree, Cancel, leaf, parallel, cycle, sequential } from "ecs-observable/bt";
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Leaf patterns
|
#### Leaf patterns
|
||||||
|
|
@ -437,7 +437,7 @@ sequential([a, b, c]) // left-to-right, all must succeed
|
||||||
selector([a, b, c]) // left-to-right, first success wins
|
selector([a, b, c]) // left-to-right, first success wins
|
||||||
parallel([a, b, c]) // all at once, all must succeed
|
parallel([a, b, c]) // all at once, all must succeed
|
||||||
random([a, b, c]) // pick one child each activation
|
random([a, b, c]) // pick one child each activation
|
||||||
repeat(a) // decorator — re-run child forever
|
cycle(a) // scheduling boundary — re-run child on future ticks
|
||||||
```
|
```
|
||||||
|
|
||||||
Non-task entity children are materialized but ignored by the runner:
|
Non-task entity children are materialized but ignored by the runner:
|
||||||
|
|
@ -464,7 +464,7 @@ const runner = buildTree(
|
||||||
updatePhysics(dt);
|
updatePhysics(dt);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
repeat(
|
cycle(
|
||||||
sequential([
|
sequential([
|
||||||
leaf(() => { handleInput(); }),
|
leaf(() => { handleInput(); }),
|
||||||
leaf(() => { render(); }),
|
leaf(() => { render(); }),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// Behaviour Tree (buildTree) — controls game flow:
|
// Behaviour Tree (buildTree) — controls game flow:
|
||||||
// parallel
|
// parallel
|
||||||
// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand
|
// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand
|
||||||
// └── repeat
|
// └── cycle
|
||||||
// └── seq (sequential)
|
// └── seq (sequential)
|
||||||
// ├── handleInput (leaf) — reads queued commands
|
// ├── handleInput (leaf) — reads queued commands
|
||||||
// └── render (leaf) — draws via blessed
|
// └── render (leaf) — draws via blessed
|
||||||
|
|
@ -27,7 +27,7 @@ import {
|
||||||
buildTree,
|
buildTree,
|
||||||
leaf,
|
leaf,
|
||||||
parallel,
|
parallel,
|
||||||
repeat,
|
cycle,
|
||||||
sequential,
|
sequential,
|
||||||
} from "../../src/bt/index";
|
} from "../../src/bt/index";
|
||||||
import { CommandQueue } from "../../src/commands/index";
|
import { CommandQueue } from "../../src/commands/index";
|
||||||
|
|
@ -88,7 +88,7 @@ const runner = buildTree(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
repeat(
|
cycle(
|
||||||
sequential([
|
sequential([
|
||||||
leaf(() => {
|
leaf(() => {
|
||||||
commands.execute();
|
commands.execute();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// Behaviour Tree (buildTree) — controls game flow:
|
// Behaviour Tree (buildTree) — controls game flow:
|
||||||
// parallel
|
// parallel
|
||||||
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer
|
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer
|
||||||
// └── repeat
|
// └── cycle
|
||||||
// └── seq (sequential)
|
// └── seq (sequential)
|
||||||
// ├── handleInput (leaf) — reads queued commands
|
// ├── handleInput (leaf) — reads queued commands
|
||||||
// └── render (leaf) — draws via blessed
|
// └── render (leaf) — draws via blessed
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
buildTree,
|
buildTree,
|
||||||
leaf,
|
leaf,
|
||||||
parallel,
|
parallel,
|
||||||
repeat,
|
cycle,
|
||||||
sequential,
|
sequential,
|
||||||
} from "../../src/bt/index";
|
} from "../../src/bt/index";
|
||||||
import { CommandQueue } from "../../src/commands/index";
|
import { CommandQueue } from "../../src/commands/index";
|
||||||
|
|
@ -95,7 +95,7 @@ const runner = buildTree(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
repeat(
|
cycle(
|
||||||
sequential([
|
sequential([
|
||||||
leaf(() => {
|
leaf(() => {
|
||||||
commands.execute();
|
commands.execute();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,6 @@ export {
|
||||||
parallel,
|
parallel,
|
||||||
selector,
|
selector,
|
||||||
random,
|
random,
|
||||||
repeat,
|
cycle,
|
||||||
} from "./tree-def";
|
} from "./tree-def";
|
||||||
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";
|
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";
|
||||||
|
|
|
||||||
218
src/bt/runner.ts
218
src/bt/runner.ts
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from "./task";
|
} from "./task";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────
|
||||||
/** Callback invoked for each leaf task that becomes Scheduled. */
|
/** Callback invoked when a leaf task starts executing. */
|
||||||
export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
|
export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
|
||||||
|
|
||||||
/** Callback invoked when a task reaches a terminal status. */
|
/** Callback invoked when a task reaches a terminal status. */
|
||||||
|
|
@ -68,10 +68,9 @@ function parentOf(world: World, child: Entity): Entity | null {
|
||||||
/**
|
/**
|
||||||
* Push-based behaviour-tree runner.
|
* Push-based behaviour-tree runner.
|
||||||
*
|
*
|
||||||
* Only tasks tagged with `Scheduled` are processed each tick.
|
* Tasks tagged with `Scheduled` define tick boundaries. Once a scheduled
|
||||||
* When a task finishes, it notifies its parent, which may schedule
|
* task starts, child/parent propagation is immediate until a running leaf or
|
||||||
* the next child (sequential), aggregate results (parallel), or
|
* an explicit scheduling boundary (such as `cycle`) yields to a future tick.
|
||||||
* propagate upward.
|
|
||||||
*
|
*
|
||||||
* Leaves are dispatched to a user-provided `onLeaf` callback.
|
* Leaves are dispatched to a user-provided `onLeaf` callback.
|
||||||
* Terminal results are dispatched to a user-provided `onTerminal` callback.
|
* Terminal results are dispatched to a user-provided `onTerminal` callback.
|
||||||
|
|
@ -90,11 +89,13 @@ function parentOf(world: World, child: Entity): Entity | null {
|
||||||
*/
|
*/
|
||||||
export class TaskRunner {
|
export class TaskRunner {
|
||||||
private _world: World;
|
private _world: World;
|
||||||
|
private _executing = new Set<Entity>();
|
||||||
|
private _currentDt = 0;
|
||||||
|
|
||||||
/** Root task entity, set by `buildTree` for convenience. */
|
/** Root task entity, set by `buildTree` for convenience. */
|
||||||
root?: Entity;
|
root?: Entity;
|
||||||
|
|
||||||
/** Called when a leaf task becomes Scheduled. */
|
/** Called when a leaf task starts executing. */
|
||||||
onLeaf: LeafHandler = () => {};
|
onLeaf: LeafHandler = () => {};
|
||||||
|
|
||||||
/** Called when any task reaches a terminal status. */
|
/** Called when any task reaches a terminal status. */
|
||||||
|
|
@ -107,18 +108,28 @@ export class TaskRunner {
|
||||||
// ── Public API ────────────────────────────────────
|
// ── Public API ────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process all Scheduled tasks.
|
* Process tasks scheduled for this tick.
|
||||||
*
|
*
|
||||||
* Call once per frame. Only entities with `Scheduled` are touched.
|
* Call once per frame. Tasks scheduled while a tick is already in progress are
|
||||||
|
* deferred until the next tick boundary, but parent/child propagation caused
|
||||||
|
* by terminal status changes is immediate.
|
||||||
*
|
*
|
||||||
* @param dt Delta time in milliseconds since last tick.
|
* @param dt Delta time in milliseconds since last tick.
|
||||||
*/
|
*/
|
||||||
tick(dt: number = 0): void {
|
tick(dt: number = 0): void {
|
||||||
|
const previousDt = this._currentDt;
|
||||||
|
this._currentDt = dt;
|
||||||
|
|
||||||
|
try {
|
||||||
const scheduled = [...this._world.query(query(Task, Scheduled))];
|
const scheduled = [...this._world.query(query(Task, Scheduled))];
|
||||||
for (const entity of scheduled) {
|
for (const entity of scheduled) {
|
||||||
|
if (!this._world.has(entity, Scheduled)) continue;
|
||||||
this._world.remove(entity, Scheduled);
|
this._world.remove(entity, Scheduled);
|
||||||
this._execute(entity, dt);
|
this._execute(entity, dt);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this._currentDt = previousDt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a leaf task as succeeded and propagate upward. */
|
/** Mark a leaf task as succeeded and propagate upward. */
|
||||||
|
|
@ -133,10 +144,10 @@ export class TaskRunner {
|
||||||
|
|
||||||
/** Cancel a task and all its descendants. */
|
/** Cancel a task and all its descendants. */
|
||||||
cancel(entity: Entity): void {
|
cancel(entity: Entity): void {
|
||||||
this._cancelTree(entity);
|
this._cancelTree(entity, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schedule a task for execution next tick. */
|
/** Schedule a task for execution at the next tick boundary. */
|
||||||
schedule(entity: Entity): void {
|
schedule(entity: Entity): void {
|
||||||
if (this._world.has(entity, Task)) {
|
if (this._world.has(entity, Task)) {
|
||||||
this._world.add(entity, Scheduled);
|
this._world.add(entity, Scheduled);
|
||||||
|
|
@ -152,28 +163,51 @@ export class TaskRunner {
|
||||||
// ── Internal execution ────────────────────────────
|
// ── Internal execution ────────────────────────────
|
||||||
|
|
||||||
private _execute(entity: Entity, dt: number): void {
|
private _execute(entity: Entity, dt: number): void {
|
||||||
const t = this._world.get(entity, Task);
|
if (!this._world.has(entity, Task) || this._executing.has(entity)) return;
|
||||||
|
|
||||||
|
const t = this._world.get(entity, Task);
|
||||||
|
this._executing.add(entity);
|
||||||
|
|
||||||
|
try {
|
||||||
switch (t.kind) {
|
switch (t.kind) {
|
||||||
case "leaf":
|
case "leaf":
|
||||||
this._executeLeaf(entity, dt);
|
this._executeLeaf(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "sequential":
|
case "sequential":
|
||||||
this._executeSequential(entity);
|
this._executeSequential(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "parallel":
|
case "parallel":
|
||||||
this._executeParallel(entity);
|
this._executeParallel(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "random":
|
case "random":
|
||||||
this._executeRandom(entity);
|
this._executeRandom(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "repeat":
|
case "cycle":
|
||||||
this._executeRepeat(entity);
|
this._executeCycle(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "selector":
|
case "selector":
|
||||||
this._executeSelector(entity);
|
this._executeSelector(entity, dt);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this._executing.delete(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _executeChild(entity: Entity, dt: number): void {
|
||||||
|
if (
|
||||||
|
!this._world.has(entity, Task) ||
|
||||||
|
isTerminal(this._world, entity) ||
|
||||||
|
this._world.has(entity, Running)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._world.has(entity, Scheduled)) {
|
||||||
|
this._world.remove(entity, Scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._execute(entity, dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeLeaf(entity: Entity, dt: number): void {
|
private _executeLeaf(entity: Entity, dt: number): void {
|
||||||
|
|
@ -181,54 +215,51 @@ export class TaskRunner {
|
||||||
this.onLeaf(this._world, entity, dt);
|
this.onLeaf(this._world, entity, dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeSequential(entity: Entity): void {
|
private _executeSequential(entity: Entity, dt: number): void {
|
||||||
const children = childrenOf(this._world, entity);
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
|
let status = terminalStatus(this._world, child);
|
||||||
// Find the first non-terminal child
|
|
||||||
for (const child of children) {
|
|
||||||
if (isTerminal(this._world, child)) {
|
|
||||||
const status = terminalStatus(this._world, child)!;
|
|
||||||
if (status === "failed" || status === "cancelled") {
|
if (status === "failed" || status === "cancelled") {
|
||||||
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// succeeded — continue to next child
|
if (status === "succeeded") continue;
|
||||||
continue;
|
|
||||||
}
|
this._executeChild(child, dt);
|
||||||
|
|
||||||
|
status = terminalStatus(this._world, child);
|
||||||
|
if (status === "failed" || status === "cancelled") {
|
||||||
|
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "succeeded") continue;
|
||||||
|
|
||||||
// Found a child that hasn't run yet — schedule it
|
|
||||||
this._world.add(child, Scheduled);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All children succeeded
|
|
||||||
this._finish(entity, Succeeded);
|
this._finish(entity, Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeParallel(entity: Entity): void {
|
private _executeParallel(entity: Entity, dt: number): void {
|
||||||
const children = childrenOf(this._world, entity);
|
|
||||||
let allDone = true;
|
let allDone = true;
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
if (isTerminal(this._world, child)) {
|
let status = terminalStatus(this._world, child);
|
||||||
const status = terminalStatus(this._world, child)!;
|
|
||||||
if (status === "failed" || status === "cancelled") {
|
if (status === "failed" || status === "cancelled") {
|
||||||
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// succeeded — this child is done
|
if (status === "succeeded") continue;
|
||||||
continue;
|
|
||||||
|
this._executeChild(child, dt);
|
||||||
|
|
||||||
|
status = terminalStatus(this._world, child);
|
||||||
|
if (status === "failed" || status === "cancelled") {
|
||||||
|
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (status === "succeeded") continue;
|
||||||
|
|
||||||
allDone = false;
|
allDone = false;
|
||||||
|
|
||||||
// Schedule if not already running or scheduled
|
|
||||||
if (
|
|
||||||
!this._world.has(child, Running) &&
|
|
||||||
!this._world.has(child, Scheduled)
|
|
||||||
) {
|
|
||||||
this._world.add(child, Scheduled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
|
|
@ -236,12 +267,12 @@ export class TaskRunner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeRandom(entity: Entity): void {
|
private _executeRandom(entity: Entity, dt: number): void {
|
||||||
// Single pass: check terminals and collect eligible children
|
|
||||||
const eligible: Entity[] = [];
|
const eligible: Entity[] = [];
|
||||||
|
|
||||||
for (const child of childrenOf(this._world, entity)) {
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
if (isTerminal(this._world, child)) {
|
const status = terminalStatus(this._world, child);
|
||||||
const status = terminalStatus(this._world, child)!;
|
if (status) {
|
||||||
this._finish(
|
this._finish(
|
||||||
entity,
|
entity,
|
||||||
status === "succeeded"
|
status === "succeeded"
|
||||||
|
|
@ -252,68 +283,73 @@ export class TaskRunner {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (!this._world.has(child, Running)) {
|
||||||
!this._world.has(child, Running) &&
|
|
||||||
!this._world.has(child, Scheduled)
|
|
||||||
) {
|
|
||||||
eligible.push(child);
|
eligible.push(child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eligible.length > 0) {
|
if (eligible.length === 0) return;
|
||||||
|
|
||||||
const pick = eligible[Math.floor(Math.random() * eligible.length)];
|
const pick = eligible[Math.floor(Math.random() * eligible.length)];
|
||||||
this._world.add(pick, Scheduled);
|
this._executeChild(pick, dt);
|
||||||
|
|
||||||
|
const status = terminalStatus(this._world, pick);
|
||||||
|
if (status) {
|
||||||
|
this._finish(
|
||||||
|
entity,
|
||||||
|
status === "succeeded"
|
||||||
|
? Succeeded
|
||||||
|
: status === "cancelled"
|
||||||
|
? Cancelled
|
||||||
|
: Failed,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// If no eligible children (all running), wait for one to finish
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeRepeat(entity: Entity): void {
|
private _executeCycle(entity: Entity, dt: number): void {
|
||||||
const children = [...childrenOf(this._world, entity)];
|
const children = [...childrenOf(this._world, entity)];
|
||||||
|
|
||||||
// Repeat expects exactly one child
|
// Cycle expects exactly one task child. Non-task child entities are ignored.
|
||||||
if (children.length === 0) return;
|
if (children.length === 0) return;
|
||||||
|
|
||||||
const child = children[0];
|
const child = children[0];
|
||||||
|
|
||||||
// If child reached a terminal, reset the entire subtree and schedule again
|
|
||||||
if (isTerminal(this._world, child)) {
|
if (isTerminal(this._world, child)) {
|
||||||
clearSubtree(this._world, child);
|
clearSubtree(this._world, child);
|
||||||
this._world.add(child, Scheduled);
|
this._world.add(child, Scheduled);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule child if not already running or scheduled
|
this._executeChild(child, dt);
|
||||||
if (
|
|
||||||
!this._world.has(child, Running) &&
|
if (isTerminal(this._world, child)) {
|
||||||
!this._world.has(child, Scheduled)
|
clearSubtree(this._world, child);
|
||||||
) {
|
|
||||||
this._world.add(child, Scheduled);
|
this._world.add(child, Scheduled);
|
||||||
}
|
}
|
||||||
// Repeat itself never terminates — it just keeps the child going
|
// Cycle itself never terminates — it just creates tick-boundary cycles.
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeSelector(entity: Entity): void {
|
private _executeSelector(entity: Entity, dt: number): void {
|
||||||
const children = childrenOf(this._world, entity);
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
|
let status = terminalStatus(this._world, child);
|
||||||
// Find the first non-terminal child
|
|
||||||
for (const child of children) {
|
|
||||||
if (isTerminal(this._world, child)) {
|
|
||||||
const status = terminalStatus(this._world, child)!;
|
|
||||||
if (status === "succeeded") {
|
if (status === "succeeded") {
|
||||||
// First success — selector succeeds
|
|
||||||
this._finish(entity, Succeeded);
|
this._finish(entity, Succeeded);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// failed or cancelled — continue to next child
|
if (status === "failed" || status === "cancelled") continue;
|
||||||
continue;
|
|
||||||
}
|
this._executeChild(child, dt);
|
||||||
|
|
||||||
|
status = terminalStatus(this._world, child);
|
||||||
|
if (status === "succeeded") {
|
||||||
|
this._finish(entity, Succeeded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "failed" || status === "cancelled") continue;
|
||||||
|
|
||||||
// Found a child that hasn't run yet — schedule it
|
|
||||||
this._world.add(child, Scheduled);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All children failed
|
|
||||||
this._finish(entity, Failed);
|
this._finish(entity, Failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,30 +367,34 @@ export class TaskRunner {
|
||||||
const status = terminalStatus(this._world, entity)!;
|
const status = terminalStatus(this._world, entity)!;
|
||||||
this.onTerminal(this._world, entity, status);
|
this.onTerminal(this._world, entity, status);
|
||||||
|
|
||||||
// Notify parent
|
|
||||||
const parent = parentOf(this._world, entity);
|
const parent = parentOf(this._world, entity);
|
||||||
if (parent) {
|
if (parent && !this._executing.has(parent)) {
|
||||||
this._world.add(parent, Scheduled);
|
if (this._world.has(parent, Scheduled)) {
|
||||||
|
this._world.remove(parent, Scheduled);
|
||||||
|
}
|
||||||
|
this._execute(parent, this._currentDt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _cancelTree(entity: Entity): void {
|
private _cancelTree(entity: Entity, notifyParent: boolean): void {
|
||||||
if (!this._world.has(entity, Task)) return;
|
if (!this._world.has(entity, Task)) return;
|
||||||
|
|
||||||
// Cancel children first
|
// Cancel children first without repeatedly waking this node while its
|
||||||
|
// subtree is still being cancelled.
|
||||||
for (const child of childrenOf(this._world, entity)) {
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
this._cancelTree(child);
|
this._cancelTree(child, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel this node
|
|
||||||
clearStatus(this._world, entity);
|
clearStatus(this._world, entity);
|
||||||
this._world.add(entity, Cancelled);
|
this._world.add(entity, Cancelled);
|
||||||
this.onTerminal(this._world, entity, "cancelled");
|
this.onTerminal(this._world, entity, "cancelled");
|
||||||
|
|
||||||
// Notify parent
|
|
||||||
const parent = parentOf(this._world, entity);
|
const parent = parentOf(this._world, entity);
|
||||||
if (parent) {
|
if (notifyParent && parent && !this._executing.has(parent)) {
|
||||||
this._world.add(parent, Scheduled);
|
if (this._world.has(parent, Scheduled)) {
|
||||||
|
this._world.remove(parent, Scheduled);
|
||||||
|
}
|
||||||
|
this._execute(parent, this._currentDt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,19 @@ import { defineRelationship } from "../relationship";
|
||||||
* - `"leaf"` — terminal node; external logic drives it to completion.
|
* - `"leaf"` — terminal node; external logic drives it to completion.
|
||||||
* - `"sequential"` — runs children one at a time, left to right.
|
* - `"sequential"` — runs children one at a time, left to right.
|
||||||
* Succeeds when all children succeed; fails when any child fails.
|
* Succeeds when all children succeed; fails when any child fails.
|
||||||
* - `"parallel"` — schedules all children at once.
|
* - `"parallel"` — starts all children at once.
|
||||||
* Succeeds when all children succeed; fails when any child fails.
|
* Succeeds when all children succeed; fails when any child fails.
|
||||||
* - `"random"` — picks one child at random each time it's scheduled.
|
* - `"random"` — picks one child at random each time it runs.
|
||||||
* Succeeds/fails with that child's result.
|
* Succeeds/fails with that child's result.
|
||||||
* - `"repeat"` — runs its single child. When the child finishes, resets it
|
* - `"cycle"` — runs its single child. When the child finishes, resets it
|
||||||
* and runs again. Never terminates on its own (only via cancel).
|
* and schedules the next run for a future tick boundary. Never terminates
|
||||||
|
* on its own (only via cancel).
|
||||||
* - `"selector"` — runs children left to right. Succeeds on the first
|
* - `"selector"` — runs children left to right. Succeeds on the first
|
||||||
* child that succeeds; fails only if all children fail.
|
* child that succeeds; fails only if all children fail.
|
||||||
*/
|
*/
|
||||||
export const Task = defineComponent("task", {
|
export const Task = defineComponent("task", {
|
||||||
kind: "leaf" as
|
kind: "leaf" as
|
||||||
| "leaf"
|
"leaf" | "sequential" | "parallel" | "random" | "cycle" | "selector",
|
||||||
| "sequential"
|
|
||||||
| "parallel"
|
|
||||||
| "random"
|
|
||||||
| "repeat"
|
|
||||||
| "selector",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TaskKind = (typeof Task.type)["kind"];
|
export type TaskKind = (typeof Task.type)["kind"];
|
||||||
|
|
|
||||||
|
|
@ -91,18 +91,18 @@ export function random(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a repeat task entity definition.
|
* Create a cycle task entity definition.
|
||||||
*
|
*
|
||||||
* `repeat(child)` and `repeat([child, metadataEntity])` are both supported.
|
* `cycle(child)` and `cycle([child, metadataEntity])` are both supported.
|
||||||
* The runner operates only on child entities that have the `Task` component.
|
* The runner operates only on child entities that have the `Task` component.
|
||||||
*/
|
*/
|
||||||
export function repeat(child: EntityDef): TaskEntityDef;
|
export function cycle(child: EntityDef): TaskEntityDef;
|
||||||
export function repeat(children?: readonly EntityDefChild[]): TaskEntityDef;
|
export function cycle(children?: readonly EntityDefChild[]): TaskEntityDef;
|
||||||
export function repeat(
|
export function cycle(
|
||||||
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
|
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
|
||||||
): TaskEntityDef {
|
): TaskEntityDef {
|
||||||
return task(
|
return task(
|
||||||
"repeat",
|
"cycle",
|
||||||
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
|
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ export function repeat(
|
||||||
* fully-wired `TaskRunner`.
|
* fully-wired `TaskRunner`.
|
||||||
*
|
*
|
||||||
* Definitions are `EntityDef` trees produced by the task factories (`leaf`,
|
* Definitions are `EntityDef` trees produced by the task factories (`leaf`,
|
||||||
* `sequential`, `parallel`, `selector`, `random`, `repeat`) and generic
|
* `sequential`, `parallel`, `selector`, `random`, `cycle`) and generic
|
||||||
* single-component entity factories. Non-task child entities are materialized
|
* single-component entity factories. Non-task child entities are materialized
|
||||||
* into the ECS tree but ignored by `TaskRunner` execution.
|
* into the ECS tree but ignored by `TaskRunner` execution.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
195
test/bt.test.ts
195
test/bt.test.ts
|
|
@ -11,7 +11,7 @@ import {
|
||||||
TaskRunner,
|
TaskRunner,
|
||||||
buildTree,
|
buildTree,
|
||||||
leaf,
|
leaf,
|
||||||
repeat,
|
cycle,
|
||||||
sequential,
|
sequential,
|
||||||
} from "../src/bt/index";
|
} from "../src/bt/index";
|
||||||
|
|
||||||
|
|
@ -44,9 +44,9 @@ function makeRandom(world: World, parent?: Entity): Entity {
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRepeat(world: World, parent?: Entity): Entity {
|
function makeCycle(world: World, parent?: Entity): Entity {
|
||||||
const e = world.spawn();
|
const e = world.spawn();
|
||||||
world.add(e, Task, { kind: "repeat" });
|
world.add(e, Task, { kind: "cycle" });
|
||||||
if (parent) world.relate(e, ChildOf, parent);
|
if (parent) world.relate(e, ChildOf, parent);
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
@ -90,15 +90,15 @@ describe("Entity task factories", () => {
|
||||||
expect(world.has(root, Succeeded)).toBe(true);
|
expect(world.has(root, Succeeded)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repeat works with component child entities beside its task child", () => {
|
it("cycle works with component child entities beside its task child", () => {
|
||||||
const Label = defineComponent("repeatLabel", { value: "" });
|
const Label = defineComponent("cycleLabel", { value: "" });
|
||||||
const world = new World();
|
const world = new World();
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
|
|
||||||
const runner = buildTree(
|
const runner = buildTree(
|
||||||
world,
|
world,
|
||||||
repeat([
|
cycle([
|
||||||
entity(Label, { value: "repeat metadata" }),
|
entity(Label, { value: "cycle metadata" }),
|
||||||
leaf(() => {
|
leaf(() => {
|
||||||
calls++;
|
calls++;
|
||||||
}),
|
}),
|
||||||
|
|
@ -218,30 +218,20 @@ describe("Sequential tasks", () => {
|
||||||
runner.onLeaf = (_w, e) => leafCalls.push(e);
|
runner.onLeaf = (_w, e) => leafCalls.push(e);
|
||||||
|
|
||||||
runner.schedule(seq);
|
runner.schedule(seq);
|
||||||
runner.tick(); // schedules first child
|
|
||||||
expect(world.has(a, Scheduled)).toBe(true);
|
|
||||||
expect(world.has(b, Scheduled)).toBe(false);
|
|
||||||
expect(world.has(c, Scheduled)).toBe(false);
|
|
||||||
|
|
||||||
runner.tick(); // runs a
|
|
||||||
runner.succeed(a);
|
|
||||||
|
|
||||||
// parent should be re-scheduled
|
|
||||||
runner.tick(); // schedules next child
|
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
runner.tick(); // runs b
|
|
||||||
runner.succeed(b);
|
|
||||||
|
|
||||||
runner.tick(); // schedules c
|
|
||||||
expect(world.has(c, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
runner.tick(); // runs c
|
|
||||||
runner.succeed(c);
|
|
||||||
|
|
||||||
// parent should now be scheduled and succeed
|
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
expect(world.has(a, Running)).toBe(true);
|
||||||
|
expect(world.has(b, Running)).toBe(false);
|
||||||
|
expect(world.has(c, Running)).toBe(false);
|
||||||
|
|
||||||
|
runner.succeed(a);
|
||||||
|
expect(world.has(b, Running)).toBe(true);
|
||||||
|
|
||||||
|
runner.succeed(b);
|
||||||
|
expect(world.has(c, Running)).toBe(true);
|
||||||
|
|
||||||
|
runner.succeed(c);
|
||||||
expect(world.has(seq, Succeeded)).toBe(true);
|
expect(world.has(seq, Succeeded)).toBe(true);
|
||||||
|
expect(leafCalls).toEqual([a, b, c]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails immediately when a child fails", () => {
|
it("fails immediately when a child fails", () => {
|
||||||
|
|
@ -319,7 +309,7 @@ describe("Parallel tasks", () => {
|
||||||
runner = new TaskRunner(world);
|
runner = new TaskRunner(world);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("schedules all children at once", () => {
|
it("starts all children at once", () => {
|
||||||
const par = makeParallel(world);
|
const par = makeParallel(world);
|
||||||
const a = makeLeaf(world, par);
|
const a = makeLeaf(world, par);
|
||||||
const b = makeLeaf(world, par);
|
const b = makeLeaf(world, par);
|
||||||
|
|
@ -328,9 +318,9 @@ describe("Parallel tasks", () => {
|
||||||
runner.schedule(par);
|
runner.schedule(par);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
|
||||||
expect(world.has(a, Scheduled)).toBe(true);
|
expect(world.has(a, Running)).toBe(true);
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
expect(world.has(b, Running)).toBe(true);
|
||||||
expect(world.has(c, Scheduled)).toBe(true);
|
expect(world.has(c, Running)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("succeeds when all children succeed", () => {
|
it("succeeds when all children succeed", () => {
|
||||||
|
|
@ -407,15 +397,13 @@ describe("Random tasks", () => {
|
||||||
runner.schedule(rand);
|
runner.schedule(rand);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
|
||||||
// Exactly one child should be scheduled
|
// Exactly one child should be running
|
||||||
const scheduled = [world.has(a, Scheduled), world.has(b, Scheduled)];
|
const running = [world.has(a, Running), world.has(b, Running)];
|
||||||
expect(scheduled.filter(Boolean)).toHaveLength(1);
|
expect(running.filter(Boolean)).toHaveLength(1);
|
||||||
|
|
||||||
const picked = world.has(a, Scheduled) ? a : b;
|
const picked = world.has(a, Running) ? a : b;
|
||||||
runner.tick(); // runs picked leaf
|
|
||||||
runner.succeed(picked);
|
runner.succeed(picked);
|
||||||
|
|
||||||
runner.tick(); // random sees child done → succeeds
|
|
||||||
expect(world.has(rand, Succeeded)).toBe(true);
|
expect(world.has(rand, Succeeded)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -444,8 +432,8 @@ describe("Random tasks", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Repeat ──────────────────────────────────────────
|
// ── Cycle ──────────────────────────────────────────
|
||||||
describe("Repeat tasks", () => {
|
describe("Cycle tasks", () => {
|
||||||
let world: World;
|
let world: World;
|
||||||
let runner: TaskRunner;
|
let runner: TaskRunner;
|
||||||
|
|
||||||
|
|
@ -455,110 +443,98 @@ describe("Repeat tasks", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-runs child after it succeeds", () => {
|
it("re-runs child after it succeeds", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
const leaf = makeLeaf(world, rep);
|
const leaf = makeLeaf(world, cyc);
|
||||||
|
|
||||||
let leafCount = 0;
|
let leafCount = 0;
|
||||||
runner.onLeaf = () => leafCount++;
|
runner.onLeaf = () => leafCount++;
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
|
|
||||||
// First run
|
runner.tick();
|
||||||
runner.tick(); // repeat schedules leaf
|
|
||||||
runner.tick(); // leaf runs
|
|
||||||
expect(leafCount).toBe(1);
|
expect(leafCount).toBe(1);
|
||||||
runner.succeed(leaf);
|
runner.succeed(leaf);
|
||||||
|
|
||||||
// Repeat re-scheduled
|
// Completion schedules the next cycle at a tick boundary.
|
||||||
runner.tick(); // repeat sees leaf done, resets and schedules again
|
expect(world.has(leaf, Scheduled)).toBe(true);
|
||||||
runner.tick(); // leaf runs again
|
runner.tick();
|
||||||
expect(leafCount).toBe(2);
|
expect(leafCount).toBe(2);
|
||||||
runner.succeed(leaf);
|
runner.succeed(leaf);
|
||||||
|
|
||||||
// And again
|
runner.tick();
|
||||||
runner.tick(); // repeat resets
|
|
||||||
runner.tick(); // leaf runs
|
|
||||||
expect(leafCount).toBe(3);
|
expect(leafCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-runs child after it fails", () => {
|
it("re-runs child after it fails", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
const leaf = makeLeaf(world, rep);
|
const leaf = makeLeaf(world, cyc);
|
||||||
|
|
||||||
let leafCount = 0;
|
let leafCount = 0;
|
||||||
runner.onLeaf = () => leafCount++;
|
runner.onLeaf = () => leafCount++;
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
runner.tick(); // repeat schedules leaf
|
runner.tick();
|
||||||
runner.tick(); // leaf runs
|
|
||||||
runner.fail(leaf);
|
runner.fail(leaf);
|
||||||
|
|
||||||
// Repeat re-scheduled, resets leaf
|
expect(world.has(leaf, Scheduled)).toBe(true);
|
||||||
runner.tick(); // repeat resets leaf
|
runner.tick();
|
||||||
runner.tick(); // leaf runs again
|
|
||||||
expect(leafCount).toBe(2);
|
expect(leafCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never terminates on its own", () => {
|
it("never terminates on its own", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
const leaf = makeLeaf(world, rep);
|
const leaf = makeLeaf(world, cyc);
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
runner.tick(); // schedules leaf
|
runner.tick();
|
||||||
runner.tick(); // runs leaf
|
|
||||||
runner.succeed(leaf);
|
runner.succeed(leaf);
|
||||||
|
|
||||||
// After many cycles, repeat is still not terminal
|
// After many cycles, cycle is still not terminal
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
runner.tick(); // repeat resets + schedules leaf
|
runner.tick();
|
||||||
runner.tick(); // leaf runs
|
|
||||||
runner.succeed(leaf);
|
runner.succeed(leaf);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(world.has(rep, Succeeded)).toBe(false);
|
expect(world.has(cyc, Succeeded)).toBe(false);
|
||||||
expect(world.has(rep, Failed)).toBe(false);
|
expect(world.has(cyc, Failed)).toBe(false);
|
||||||
expect(world.has(rep, Cancelled)).toBe(false);
|
expect(world.has(cyc, Cancelled)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can be cancelled", () => {
|
it("can be cancelled", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
const leaf = makeLeaf(world, rep);
|
const leaf = makeLeaf(world, cyc);
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
runner.tick(); // schedules leaf
|
runner.tick();
|
||||||
runner.tick(); // runs leaf
|
|
||||||
|
|
||||||
runner.cancel(rep);
|
runner.cancel(cyc);
|
||||||
|
|
||||||
expect(world.has(rep, Cancelled)).toBe(true);
|
expect(world.has(cyc, Cancelled)).toBe(true);
|
||||||
expect(world.has(leaf, Cancelled)).toBe(true);
|
expect(world.has(leaf, Cancelled)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty repeat does nothing", () => {
|
it("empty cycle does nothing", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
|
||||||
// No child, so nothing happens
|
// No child, so nothing happens
|
||||||
expect(world.has(rep, Succeeded)).toBe(false);
|
expect(world.has(cyc, Succeeded)).toBe(false);
|
||||||
expect(world.has(rep, Failed)).toBe(false);
|
expect(world.has(cyc, Failed)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repeat inside sequential advances parent when cancelled", () => {
|
it("cycle inside sequential advances parent when cancelled", () => {
|
||||||
const seq = makeSequential(world);
|
const seq = makeSequential(world);
|
||||||
const rep = makeRepeat(world, seq);
|
const cyc = makeCycle(world, seq);
|
||||||
const leaf = makeLeaf(world, rep);
|
const leaf = makeLeaf(world, cyc);
|
||||||
const after = makeLeaf(world, seq);
|
const after = makeLeaf(world, seq);
|
||||||
|
|
||||||
runner.schedule(seq);
|
runner.schedule(seq);
|
||||||
runner.tick(); // seq schedules rep
|
runner.tick();
|
||||||
runner.tick(); // rep schedules leaf
|
|
||||||
runner.tick(); // leaf runs
|
|
||||||
|
|
||||||
// Cancel the repeat
|
// Cancel the cycle
|
||||||
runner.cancel(rep);
|
runner.cancel(cyc);
|
||||||
runner.tick(); // seq sees rep cancelled, seq becomes cancelled
|
|
||||||
|
|
||||||
expect(world.has(seq, Cancelled)).toBe(true);
|
expect(world.has(seq, Cancelled)).toBe(true);
|
||||||
expect(world.has(after, Scheduled)).toBe(false);
|
expect(world.has(after, Scheduled)).toBe(false);
|
||||||
|
|
@ -604,19 +580,12 @@ describe("Selector tasks", () => {
|
||||||
runner.tick(); // runs a
|
runner.tick(); // runs a
|
||||||
runner.fail(a);
|
runner.fail(a);
|
||||||
|
|
||||||
runner.tick(); // selector schedules b
|
expect(world.has(b, Running)).toBe(true);
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
runner.tick(); // runs b
|
|
||||||
runner.fail(b);
|
runner.fail(b);
|
||||||
|
expect(world.has(c, Running)).toBe(true);
|
||||||
|
|
||||||
runner.tick(); // selector schedules c
|
|
||||||
expect(world.has(c, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
runner.tick(); // runs c
|
|
||||||
runner.succeed(c);
|
runner.succeed(c);
|
||||||
|
|
||||||
runner.tick(); // selector succeeds
|
|
||||||
expect(world.has(sel, Succeeded)).toBe(true);
|
expect(world.has(sel, Succeeded)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -656,8 +625,7 @@ describe("Selector tasks", () => {
|
||||||
runner.tick(); // runs a
|
runner.tick(); // runs a
|
||||||
runner.cancel(a);
|
runner.cancel(a);
|
||||||
|
|
||||||
runner.tick(); // selector sees a cancelled, tries b
|
expect(world.has(b, Running)).toBe(true);
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -756,10 +724,9 @@ describe("Multi-frame leaves", () => {
|
||||||
expect(world.has(b, Scheduled)).toBe(false);
|
expect(world.has(b, Scheduled)).toBe(false);
|
||||||
expect(world.has(b, Running)).toBe(false);
|
expect(world.has(b, Running)).toBe(false);
|
||||||
|
|
||||||
// Finish a
|
// Finish a; parent propagation starts b immediately.
|
||||||
runner.succeed(a);
|
runner.succeed(a);
|
||||||
runner.tick(); // seq schedules b
|
expect(world.has(b, Running)).toBe(true);
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -804,25 +771,11 @@ describe("Edge cases", () => {
|
||||||
|
|
||||||
runner.schedule(root);
|
runner.schedule(root);
|
||||||
|
|
||||||
// Tick 1: root schedules mid
|
|
||||||
runner.tick();
|
|
||||||
expect(world.has(mid, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
// Tick 2: mid schedules leaf
|
|
||||||
runner.tick();
|
|
||||||
expect(world.has(leaf, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
// Tick 3: leaf runs
|
|
||||||
runner.tick();
|
runner.tick();
|
||||||
expect(world.has(leaf, Running)).toBe(true);
|
expect(world.has(leaf, Running)).toBe(true);
|
||||||
|
|
||||||
runner.succeed(leaf);
|
runner.succeed(leaf);
|
||||||
// leaf done → mid scheduled
|
|
||||||
|
|
||||||
runner.tick(); // mid sees leaf done → mid succeeds
|
|
||||||
expect(world.has(mid, Succeeded)).toBe(true);
|
expect(world.has(mid, Succeeded)).toBe(true);
|
||||||
|
|
||||||
runner.tick(); // root sees mid done → root succeeds
|
|
||||||
expect(world.has(root, Succeeded)).toBe(true);
|
expect(world.has(root, Succeeded)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue