feat(bt): Add wait and whilst, rename leaf to action
This commit is contained in:
parent
204b9100e6
commit
9bf0e5e049
48
USAGE.md
48
USAGE.md
|
|
@ -397,37 +397,44 @@ Behaviour trees control game flow by composing tasks into a tree. Each node in t
|
|||
|
||||
```ts
|
||||
import { defineComponent, entity } from "ecs-observable";
|
||||
import { buildTree, Cancel, leaf, parallel, cycle, sequential } from "ecs-observable/bt";
|
||||
import { buildTree, Cancel, action, wait, parallel, cycle, whilst, sequential } from "ecs-observable/bt";
|
||||
```
|
||||
|
||||
#### Leaf patterns
|
||||
#### Task patterns
|
||||
|
||||
**One-shot** — just return. Implicit success.
|
||||
**Action** — runs immediately. Normal return = success.
|
||||
```ts
|
||||
leaf(() => { doWork(); })
|
||||
action(() => { doWork(); })
|
||||
```
|
||||
|
||||
**Fail** — throw any error.
|
||||
```ts
|
||||
leaf(() => { throw new Error("bad"); })
|
||||
action(() => { throw new Error("bad"); })
|
||||
```
|
||||
|
||||
**Cancel** — throw the `Cancel` symbol.
|
||||
```ts
|
||||
leaf(() => { throw Cancel; })
|
||||
action(() => { throw Cancel; })
|
||||
```
|
||||
|
||||
**Ongoing** — generator function. Each `yield` suspends until next tick. The yielded value is the delay in ms (or nothing for next frame). Completion = success.
|
||||
**Wait** — starts once and stays running until external code completes it.
|
||||
```ts
|
||||
leaf(function* tickTimer() {
|
||||
while (true) {
|
||||
const dt: number = yield; // delta time from runner.tick(dt)
|
||||
wait((world, entity, task) => {
|
||||
startAnimation(() => task.succeed());
|
||||
})
|
||||
```
|
||||
|
||||
**Whilst** — runs its child while a condition is true, yielding at tick boundaries between successful iterations.
|
||||
```ts
|
||||
whilst(
|
||||
() => true,
|
||||
action((_world, _entity, dt) => {
|
||||
timer.accumulator += dt;
|
||||
if (timer.accumulator >= timer.interval) {
|
||||
// ... act ...
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
#### Composite nodes
|
||||
|
|
@ -438,6 +445,7 @@ selector([a, b, c]) // left-to-right, first success wins
|
|||
parallel([a, b, c]) // all at once, all must succeed
|
||||
random([a, b, c]) // pick one child each activation
|
||||
cycle(a) // scheduling boundary — re-run child on future ticks
|
||||
whilst(test, a) // conditional scheduling boundary
|
||||
```
|
||||
|
||||
Non-task entity children are materialized but ignored by the runner:
|
||||
|
|
@ -447,8 +455,8 @@ const Label = defineComponent("label", { value: "" });
|
|||
|
||||
sequential([
|
||||
entity(Label, { value: "main sequence" }),
|
||||
leaf(handleInput),
|
||||
leaf(render),
|
||||
action(handleInput),
|
||||
action(render),
|
||||
])
|
||||
```
|
||||
|
||||
|
|
@ -458,16 +466,16 @@ sequential([
|
|||
const runner = buildTree(
|
||||
world,
|
||||
parallel([
|
||||
leaf(function* physicsLoop() {
|
||||
while (true) {
|
||||
const dt: number = yield;
|
||||
whilst(
|
||||
() => true,
|
||||
action((_world, _entity, dt) => {
|
||||
updatePhysics(dt);
|
||||
}
|
||||
}),
|
||||
),
|
||||
cycle(
|
||||
sequential([
|
||||
leaf(() => { handleInput(); }),
|
||||
leaf(() => { render(); }),
|
||||
action(() => { handleInput(); }),
|
||||
action(() => { render(); }),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
// Architecture:
|
||||
// Behaviour Tree (buildTree) — controls game flow:
|
||||
// parallel
|
||||
// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand
|
||||
// ├── dealerPlay (action) — generator loop, auto-plays dealer hand
|
||||
// └── cycle
|
||||
// └── seq (sequential)
|
||||
// ├── handleInput (leaf) — reads queued commands
|
||||
// └── render (leaf) — draws via blessed
|
||||
// ├── handleInput (action) — reads queued commands
|
||||
// └── render (action) — draws via blessed
|
||||
//
|
||||
// CommandQueue — processes input:
|
||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||
|
|
@ -25,10 +25,11 @@
|
|||
import { World } from "../../src/index";
|
||||
import {
|
||||
buildTree,
|
||||
leaf,
|
||||
action,
|
||||
parallel,
|
||||
cycle,
|
||||
sequential,
|
||||
whilst,
|
||||
} from "../../src/bt/index";
|
||||
import { CommandQueue } from "../../src/commands/index";
|
||||
|
||||
|
|
@ -72,11 +73,11 @@ registerCommands(world, commands, cards);
|
|||
const runner = buildTree(
|
||||
world,
|
||||
parallel([
|
||||
leaf(function* dealerPlay() {
|
||||
while (true) {
|
||||
yield;
|
||||
whilst(
|
||||
() => true,
|
||||
action(() => {
|
||||
const phase = world.getSingleton(GamePhase);
|
||||
if (phase.phase !== "dealerTurn") continue;
|
||||
if (phase.phase !== "dealerTurn") return;
|
||||
|
||||
if (dealerShouldHit(cards.getHand(InDealerHand))) {
|
||||
const cardEntity = cards.drawCard();
|
||||
|
|
@ -86,14 +87,14 @@ const runner = buildTree(
|
|||
} else {
|
||||
resolveRound(world, cards);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
cycle(
|
||||
sequential([
|
||||
leaf(() => {
|
||||
action(() => {
|
||||
commands.execute();
|
||||
}),
|
||||
leaf(() => {
|
||||
action(() => {
|
||||
render(world, ui);
|
||||
}),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
// Architecture:
|
||||
// Behaviour Tree (buildTree) — controls game flow:
|
||||
// parallel
|
||||
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer
|
||||
// ├── gravityTick (action) — generator loop, auto-drop piece on timer
|
||||
// └── cycle
|
||||
// └── seq (sequential)
|
||||
// ├── handleInput (leaf) — reads queued commands
|
||||
// └── render (leaf) — draws via blessed
|
||||
// ├── handleInput (action) — reads queued commands
|
||||
// └── render (action) — draws via blessed
|
||||
//
|
||||
// CommandQueue — processes input:
|
||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||
|
|
@ -22,10 +22,11 @@
|
|||
import { World } from "../../src/index";
|
||||
import {
|
||||
buildTree,
|
||||
leaf,
|
||||
action,
|
||||
parallel,
|
||||
cycle,
|
||||
sequential,
|
||||
whilst,
|
||||
} from "../../src/bt/index";
|
||||
import { CommandQueue } from "../../src/commands/index";
|
||||
|
||||
|
|
@ -73,11 +74,11 @@ pieces.spawnPiece();
|
|||
const runner = buildTree(
|
||||
world,
|
||||
parallel([
|
||||
leaf(function* gravityTick() {
|
||||
while (true) {
|
||||
const dt: number = yield;
|
||||
whilst(
|
||||
() => true,
|
||||
action((_world, _entity, dt) => {
|
||||
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
const timer = world.getSingleton(TickTimer);
|
||||
timer.accumulator += dt;
|
||||
|
|
@ -93,14 +94,14 @@ const runner = buildTree(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
cycle(
|
||||
sequential([
|
||||
leaf(() => {
|
||||
action(() => {
|
||||
commands.execute();
|
||||
}),
|
||||
leaf(() => {
|
||||
action(() => {
|
||||
render(world, ui);
|
||||
}),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -11,16 +11,31 @@ export {
|
|||
export type { TaskKind } from "./task";
|
||||
|
||||
export { TaskRunner } from "./runner";
|
||||
export type { LeafHandler, TerminalHandler } from "./runner";
|
||||
export type {
|
||||
ActionHandler,
|
||||
WaitHandler,
|
||||
ConditionHandler,
|
||||
TaskControl,
|
||||
TerminalHandler,
|
||||
} from "./runner";
|
||||
|
||||
export {
|
||||
buildTree,
|
||||
Cancel,
|
||||
leaf,
|
||||
action,
|
||||
wait,
|
||||
whilst,
|
||||
sequential,
|
||||
parallel,
|
||||
selector,
|
||||
random,
|
||||
cycle,
|
||||
} from "./tree-def";
|
||||
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";
|
||||
export type {
|
||||
TreeDef,
|
||||
TaskEntityDef,
|
||||
TaskMeta,
|
||||
ActionFn,
|
||||
WaitFn,
|
||||
ConditionFn,
|
||||
} from "./tree-def";
|
||||
|
|
|
|||
161
src/bt/runner.ts
161
src/bt/runner.ts
|
|
@ -9,11 +9,34 @@ import {
|
|||
Cancelled,
|
||||
TERMINAL_TAGS,
|
||||
ChildOf,
|
||||
Cancel,
|
||||
} from "./task";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────
|
||||
/** Callback invoked when a leaf task starts executing. */
|
||||
export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
|
||||
/** Control object passed to wait tasks so they can complete themselves. */
|
||||
export interface TaskControl {
|
||||
succeed(): void;
|
||||
fail(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/** Callback invoked when an action task starts executing. */
|
||||
export type ActionHandler = (world: World, entity: Entity, dt: number) => void;
|
||||
|
||||
/** Callback invoked when a wait task starts executing. */
|
||||
export type WaitHandler = (
|
||||
world: World,
|
||||
entity: Entity,
|
||||
control: TaskControl,
|
||||
dt: number,
|
||||
) => void;
|
||||
|
||||
/** Callback invoked by whilst tasks before each iteration. */
|
||||
export type ConditionHandler = (
|
||||
world: World,
|
||||
entity: Entity,
|
||||
dt: number,
|
||||
) => boolean;
|
||||
|
||||
/** Callback invoked when a task reaches a terminal status. */
|
||||
export type TerminalHandler = (
|
||||
|
|
@ -69,23 +92,11 @@ function parentOf(world: World, child: Entity): Entity | null {
|
|||
* Push-based behaviour-tree runner.
|
||||
*
|
||||
* Tasks tagged with `Scheduled` define tick boundaries. Once a scheduled
|
||||
* task starts, child/parent propagation is immediate until a running leaf or
|
||||
* an explicit scheduling boundary (such as `cycle`) yields to a future tick.
|
||||
* task starts, child/parent propagation is immediate until a running wait task
|
||||
* or an explicit scheduling boundary (`cycle`, `whilst`) yields to a future tick.
|
||||
*
|
||||
* Leaves are dispatched to a user-provided `onLeaf` callback.
|
||||
* Terminal results are dispatched to a user-provided `onTerminal` callback.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const runner = new TaskRunner(world);
|
||||
* runner.onLeaf = (world, entity) => {
|
||||
* // do the leaf's work, then call:
|
||||
* runner.succeed(entity);
|
||||
* };
|
||||
*
|
||||
* // Each frame:
|
||||
* runner.tick();
|
||||
* ```
|
||||
* Action, wait, and whilst condition callbacks are supplied by `buildTree` or
|
||||
* assigned directly when using `TaskRunner` manually.
|
||||
*/
|
||||
export class TaskRunner {
|
||||
private _world: World;
|
||||
|
|
@ -95,8 +106,14 @@ export class TaskRunner {
|
|||
/** Root task entity, set by `buildTree` for convenience. */
|
||||
root?: Entity;
|
||||
|
||||
/** Called when a leaf task starts executing. */
|
||||
onLeaf: LeafHandler = () => {};
|
||||
/** Called when an action task starts executing. */
|
||||
onAction: ActionHandler = () => {};
|
||||
|
||||
/** Called when a wait task starts executing. */
|
||||
onWait: WaitHandler = () => {};
|
||||
|
||||
/** Called by whilst tasks before each iteration. */
|
||||
onCondition: ConditionHandler = () => false;
|
||||
|
||||
/** Called when any task reaches a terminal status. */
|
||||
onTerminal: TerminalHandler = () => {};
|
||||
|
|
@ -132,12 +149,12 @@ export class TaskRunner {
|
|||
}
|
||||
}
|
||||
|
||||
/** Mark a leaf task as succeeded and propagate upward. */
|
||||
/** Mark a task as succeeded and propagate upward. */
|
||||
succeed(entity: Entity): void {
|
||||
this._finish(entity, Succeeded);
|
||||
}
|
||||
|
||||
/** Mark a leaf task as failed and propagate upward. */
|
||||
/** Mark a task as failed and propagate upward. */
|
||||
fail(entity: Entity): void {
|
||||
this._finish(entity, Failed);
|
||||
}
|
||||
|
|
@ -170,8 +187,11 @@ export class TaskRunner {
|
|||
|
||||
try {
|
||||
switch (t.kind) {
|
||||
case "leaf":
|
||||
this._executeLeaf(entity, dt);
|
||||
case "action":
|
||||
this._executeAction(entity, dt);
|
||||
break;
|
||||
case "wait":
|
||||
this._executeWait(entity, dt);
|
||||
break;
|
||||
case "sequential":
|
||||
this._executeSequential(entity, dt);
|
||||
|
|
@ -185,6 +205,9 @@ export class TaskRunner {
|
|||
case "cycle":
|
||||
this._executeCycle(entity, dt);
|
||||
break;
|
||||
case "whilst":
|
||||
this._executeWhilst(entity, dt);
|
||||
break;
|
||||
case "selector":
|
||||
this._executeSelector(entity, dt);
|
||||
break;
|
||||
|
|
@ -210,9 +233,31 @@ export class TaskRunner {
|
|||
this._execute(entity, dt);
|
||||
}
|
||||
|
||||
private _executeLeaf(entity: Entity, dt: number): void {
|
||||
private _executeAction(entity: Entity, dt: number): void {
|
||||
this._world.add(entity, Running);
|
||||
this.onLeaf(this._world, entity, dt);
|
||||
try {
|
||||
this.onAction(this._world, entity, dt);
|
||||
if (!isTerminal(this._world, entity)) {
|
||||
this.succeed(entity);
|
||||
}
|
||||
} catch (err) {
|
||||
this._finishFromThrown(entity, err);
|
||||
}
|
||||
}
|
||||
|
||||
private _executeWait(entity: Entity, dt: number): void {
|
||||
this._world.add(entity, Running);
|
||||
const control: TaskControl = {
|
||||
succeed: () => this.succeed(entity),
|
||||
fail: () => this.fail(entity),
|
||||
cancel: () => this.cancel(entity),
|
||||
};
|
||||
|
||||
try {
|
||||
this.onWait(this._world, entity, control, dt);
|
||||
} catch (err) {
|
||||
this._finishFromThrown(entity, err);
|
||||
}
|
||||
}
|
||||
|
||||
private _executeSequential(entity: Entity, dt: number): void {
|
||||
|
|
@ -307,28 +352,51 @@ export class TaskRunner {
|
|||
}
|
||||
|
||||
private _executeCycle(entity: Entity, dt: number): void {
|
||||
const children = [...childrenOf(this._world, entity)];
|
||||
|
||||
// Cycle expects exactly one task child. Non-task child entities are ignored.
|
||||
if (children.length === 0) return;
|
||||
|
||||
const child = children[0];
|
||||
|
||||
if (isTerminal(this._world, child)) {
|
||||
clearSubtree(this._world, child);
|
||||
this._world.add(child, Scheduled);
|
||||
return;
|
||||
}
|
||||
const child = this._firstTaskChild(entity);
|
||||
if (!child) return;
|
||||
|
||||
this._executeChild(child, dt);
|
||||
|
||||
if (isTerminal(this._world, child)) {
|
||||
clearSubtree(this._world, child);
|
||||
this._world.add(child, Scheduled);
|
||||
this._world.add(entity, Scheduled);
|
||||
}
|
||||
// Cycle itself never terminates — it just creates tick-boundary cycles.
|
||||
}
|
||||
|
||||
private _executeWhilst(entity: Entity, dt: number): void {
|
||||
let condition: boolean;
|
||||
try {
|
||||
condition = this.onCondition(this._world, entity, dt);
|
||||
} catch (err) {
|
||||
this._finishFromThrown(entity, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!condition) {
|
||||
this._finish(entity, Succeeded);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = this._firstTaskChild(entity);
|
||||
if (!child) {
|
||||
this._finish(entity, Succeeded);
|
||||
return;
|
||||
}
|
||||
|
||||
this._executeChild(child, dt);
|
||||
|
||||
const status = terminalStatus(this._world, child);
|
||||
if (status === "succeeded") {
|
||||
clearSubtree(this._world, child);
|
||||
this._world.add(entity, Scheduled);
|
||||
} else if (status === "failed") {
|
||||
this._finish(entity, Failed);
|
||||
} else if (status === "cancelled") {
|
||||
this._finish(entity, Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
private _executeSelector(entity: Entity, dt: number): void {
|
||||
for (const child of childrenOf(this._world, entity)) {
|
||||
let status = terminalStatus(this._world, child);
|
||||
|
|
@ -353,8 +421,23 @@ export class TaskRunner {
|
|||
this._finish(entity, Failed);
|
||||
}
|
||||
|
||||
private _firstTaskChild(entity: Entity): Entity | null {
|
||||
for (const child of childrenOf(this._world, entity)) {
|
||||
return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Completion propagation ────────────────────────
|
||||
|
||||
private _finishFromThrown(entity: Entity, err: unknown): void {
|
||||
if (err === Cancel) {
|
||||
this.cancel(entity);
|
||||
} else {
|
||||
this.fail(entity);
|
||||
}
|
||||
}
|
||||
|
||||
private _finish(
|
||||
entity: Entity,
|
||||
tag: typeof Succeeded | typeof Failed | typeof Cancelled,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import { defineComponent } from "../component";
|
||||
import { defineRelationship } from "../relationship";
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────
|
||||
|
||||
/** Throw from an action or wait starter to cancel that task and its subtree. */
|
||||
export const Cancel: unique symbol = Symbol("task.cancel");
|
||||
|
||||
// ── Task component ────────────────────────────────────
|
||||
/**
|
||||
* Core component for behaviour-tree tasks.
|
||||
*
|
||||
* `kind` determines how the task evaluates its children:
|
||||
* - `"leaf"` — terminal node; external logic drives it to completion.
|
||||
* `kind` determines how the task evaluates:
|
||||
* - `"action"` — runs immediately and succeeds when its function returns.
|
||||
* - `"wait"` — starts once and remains running until external code completes it.
|
||||
* - `"sequential"` — runs children one at a time, left to right.
|
||||
* Succeeds when all children succeed; fails when any child fails.
|
||||
* - `"parallel"` — starts all children at once.
|
||||
|
|
@ -16,12 +22,21 @@ import { defineRelationship } from "../relationship";
|
|||
* - `"cycle"` — runs its single child. When the child finishes, resets it
|
||||
* and schedules the next run for a future tick boundary. Never terminates
|
||||
* on its own (only via cancel).
|
||||
* - `"whilst"` — runs its single child while its condition is true.
|
||||
* Succeeds when the condition becomes false; fails/cancels with its child.
|
||||
* - `"selector"` — runs children left to right. Succeeds on the first
|
||||
* child that succeeds; fails only if all children fail.
|
||||
*/
|
||||
export const Task = defineComponent("task", {
|
||||
kind: "leaf" as
|
||||
"leaf" | "sequential" | "parallel" | "random" | "cycle" | "selector",
|
||||
kind: "action" as
|
||||
| "action"
|
||||
| "wait"
|
||||
| "sequential"
|
||||
| "parallel"
|
||||
| "random"
|
||||
| "cycle"
|
||||
| "whilst"
|
||||
| "selector",
|
||||
});
|
||||
|
||||
export type TaskKind = (typeof Task.type)["kind"];
|
||||
|
|
@ -30,7 +45,7 @@ export type TaskKind = (typeof Task.type)["kind"];
|
|||
/** A task that should be executed this tick. */
|
||||
export const Scheduled = defineComponent("scheduled", {});
|
||||
|
||||
/** A task that is currently executing (multi-frame leaves). */
|
||||
/** A task that is currently executing or waiting for completion. */
|
||||
export const Running = defineComponent("running", {});
|
||||
|
||||
/** The task completed successfully. */
|
||||
|
|
|
|||
|
|
@ -1,35 +1,32 @@
|
|||
import type { World, Entity } from "../index";
|
||||
import type { EntityDef, EntityDefChild } from "../entity-tree";
|
||||
import { Task, ChildOf } from "./task";
|
||||
import { TaskRunner } from "./runner";
|
||||
import { Task, ChildOf, Cancel } from "./task";
|
||||
import { TaskRunner, type TaskControl } from "./runner";
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────
|
||||
export { Cancel };
|
||||
|
||||
/**
|
||||
* Throw this inside a leaf `run` function to cancel the leaf and its subtree.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* leaf(() => { throw Cancel; })
|
||||
* ```
|
||||
*/
|
||||
export const Cancel: unique symbol = Symbol("leaf.cancel");
|
||||
// ── Task callback types ───────────────────────────────
|
||||
|
||||
// ── Tree definition ───────────────────────────────────
|
||||
/** Runs immediately. Return = success, throw = failure, throw Cancel = cancel. */
|
||||
export type ActionFn = (world: World, entity: Entity, dt: number) => void;
|
||||
|
||||
/** A leaf function — plain or generator. */
|
||||
export type LeafFn =
|
||||
| ((world: World, dt: number) => void)
|
||||
| (() => Generator<number | void, void, number>);
|
||||
/** Starts a task that remains Running until completed by the supplied control. */
|
||||
export type WaitFn = (
|
||||
world: World,
|
||||
entity: Entity,
|
||||
control: TaskControl,
|
||||
dt: number,
|
||||
) => void;
|
||||
|
||||
export interface LeafTaskMeta {
|
||||
readonly run: LeafFn;
|
||||
}
|
||||
/** Controls a `whilst` task. False means the loop has completed successfully. */
|
||||
export type ConditionFn = (world: World, entity: Entity, dt: number) => boolean;
|
||||
|
||||
export type TaskEntityDef = EntityDef<
|
||||
typeof Task.type,
|
||||
LeafTaskMeta | undefined
|
||||
>;
|
||||
export type TaskMeta =
|
||||
| { readonly mode: "action"; readonly run: ActionFn }
|
||||
| { readonly mode: "wait"; readonly start?: WaitFn }
|
||||
| { readonly mode: "whilst"; readonly condition: ConditionFn };
|
||||
|
||||
export type TaskEntityDef = EntityDef<typeof Task.type, TaskMeta | undefined>;
|
||||
|
||||
/** Behaviour-tree definition accepted by `buildTree`. */
|
||||
export type TreeDef = TaskEntityDef;
|
||||
|
|
@ -43,7 +40,7 @@ function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
|
|||
function task(
|
||||
kind: typeof Task.type.kind,
|
||||
children: readonly EntityDefChild[] = [],
|
||||
meta?: LeafTaskMeta,
|
||||
meta?: TaskMeta,
|
||||
): TaskEntityDef {
|
||||
return {
|
||||
kind: "entity",
|
||||
|
|
@ -54,12 +51,38 @@ function task(
|
|||
};
|
||||
}
|
||||
|
||||
/** Create a leaf task entity definition. */
|
||||
export function leaf(
|
||||
run: LeafFn,
|
||||
/** Create an action task entity definition. */
|
||||
export function action(
|
||||
run: ActionFn,
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task("leaf", children, { run });
|
||||
return task("action", children, { mode: "action", run });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wait task entity definition.
|
||||
*
|
||||
* `wait()` creates a task that simply becomes Running. External systems can
|
||||
* complete it with `runner.succeed(entity)`, `runner.fail(entity)`, or
|
||||
* `runner.cancel(entity)`.
|
||||
*/
|
||||
export function wait(children?: readonly EntityDefChild[]): TaskEntityDef;
|
||||
export function wait(
|
||||
start: WaitFn,
|
||||
children?: readonly EntityDefChild[],
|
||||
): TaskEntityDef;
|
||||
export function wait(
|
||||
startOrChildren?: WaitFn | readonly EntityDefChild[],
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
const hasChildrenAsFirstArg = Array.isArray(startOrChildren);
|
||||
const start = hasChildrenAsFirstArg
|
||||
? undefined
|
||||
: (startOrChildren as WaitFn | undefined);
|
||||
return task("wait", hasChildrenAsFirstArg ? startOrChildren : children, {
|
||||
mode: "wait",
|
||||
start,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a sequential task entity definition. */
|
||||
|
|
@ -107,28 +130,44 @@ export function cycle(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a conditional loop task entity definition.
|
||||
*
|
||||
* Runs its child while `condition` returns true. When the child succeeds, the
|
||||
* child subtree is reset and `whilst` schedules itself for the next tick
|
||||
* boundary. When `condition` returns false, `whilst` succeeds.
|
||||
*/
|
||||
export function whilst(condition: ConditionFn, child: EntityDef): TaskEntityDef;
|
||||
export function whilst(
|
||||
condition: ConditionFn,
|
||||
children?: readonly EntityDefChild[],
|
||||
): TaskEntityDef;
|
||||
export function whilst(
|
||||
condition: ConditionFn,
|
||||
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task(
|
||||
"whilst",
|
||||
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
|
||||
{ mode: "whilst", condition },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Builder ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Materialize a behaviour-tree definition into ECS entities and return a
|
||||
* fully-wired `TaskRunner`.
|
||||
*
|
||||
* Definitions are `EntityDef` trees produced by the task factories (`leaf`,
|
||||
* `sequential`, `parallel`, `selector`, `random`, `cycle`) and generic
|
||||
* single-component entity factories. Non-task child entities are materialized
|
||||
* into the ECS tree but ignored by `TaskRunner` execution.
|
||||
*
|
||||
* Leaf `run` functions:
|
||||
* - **Plain function** — runs once per tick. `return` = success. `throw` = fail.
|
||||
* `throw Cancel` = cancel.
|
||||
* - **Generator function** — each `yield` suspends until next tick. The value
|
||||
* yielded is the desired delay in ms (or `undefined` for next frame).
|
||||
* Generator completion = success. `throw` = fail. `throw Cancel` = cancel.
|
||||
* Definitions are `EntityDef` trees produced by the task factories (`action`,
|
||||
* `wait`, `sequential`, `parallel`, `selector`, `random`, `cycle`, `whilst`)
|
||||
* and generic single-component entity factories. Non-task child entities are
|
||||
* materialized into the ECS tree but ignored by `TaskRunner` execution.
|
||||
*/
|
||||
export function buildTree(world: World, def: TreeDef): TaskRunner {
|
||||
const leafHandlers = new Map<Entity, LeafFn>();
|
||||
// Track generator iterators for multi-frame leaves
|
||||
const generators = new Map<Entity, Generator<number | void, void, number>>();
|
||||
const actions = new Map<Entity, ActionFn>();
|
||||
const waits = new Map<Entity, WaitFn>();
|
||||
const conditions = new Map<Entity, ConditionFn>();
|
||||
|
||||
function build(def: EntityDef, parent?: Entity): Entity {
|
||||
const entity = world.spawn();
|
||||
|
|
@ -140,12 +179,22 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
|
|||
|
||||
if (def.component === Task) {
|
||||
const taskData = world.get(entity, Task);
|
||||
if (taskData.kind === "leaf") {
|
||||
const run = (def.meta as LeafTaskMeta | undefined)?.run;
|
||||
if (!run) {
|
||||
throw new Error("Leaf task entity is missing a run function");
|
||||
const meta = def.meta as TaskMeta | undefined;
|
||||
|
||||
if (taskData.kind === "action") {
|
||||
if (meta?.mode !== "action") {
|
||||
throw new Error("Action task entity is missing an action function");
|
||||
}
|
||||
leafHandlers.set(entity, run);
|
||||
actions.set(entity, meta.run);
|
||||
} else if (taskData.kind === "wait") {
|
||||
if (meta?.mode === "wait" && meta.start) {
|
||||
waits.set(entity, meta.start);
|
||||
}
|
||||
} else if (taskData.kind === "whilst") {
|
||||
if (meta?.mode !== "whilst") {
|
||||
throw new Error("Whilst task entity is missing a condition function");
|
||||
}
|
||||
conditions.set(entity, meta.condition);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,56 +214,17 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
|
|||
const runner = new TaskRunner(world);
|
||||
runner.root = root;
|
||||
|
||||
runner.onLeaf = (_w, entity, dt) => {
|
||||
const handler = leafHandlers.get(entity);
|
||||
if (!handler) return;
|
||||
|
||||
try {
|
||||
// Check if this leaf has an active generator
|
||||
let gen = generators.get(entity);
|
||||
|
||||
if (gen) {
|
||||
// Resume existing generator
|
||||
const result = gen.next(dt);
|
||||
if (result.done) {
|
||||
generators.delete(entity);
|
||||
runner.succeed(entity);
|
||||
}
|
||||
// If not done, leaf stays Running — nothing to do
|
||||
} else {
|
||||
// First invocation — call the handler
|
||||
const ret = handler(_w, dt);
|
||||
|
||||
// Check if it returned a generator
|
||||
if (ret != null && typeof (ret as any).next === "function") {
|
||||
const gen = ret as Generator<number | void, void, number>;
|
||||
generators.set(entity, gen);
|
||||
const result = gen.next(dt);
|
||||
if (result.done) {
|
||||
generators.delete(entity);
|
||||
runner.succeed(entity);
|
||||
}
|
||||
// Not done → leaf stays Running
|
||||
} else {
|
||||
// Plain function — returned undefined → success
|
||||
runner.succeed(entity);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Clean up generator if one was active
|
||||
generators.delete(entity);
|
||||
|
||||
if (err === Cancel) {
|
||||
runner.cancel(entity);
|
||||
} else {
|
||||
runner.fail(entity);
|
||||
}
|
||||
}
|
||||
runner.onAction = (world, entity, dt) => {
|
||||
actions.get(entity)?.(world, entity, dt);
|
||||
};
|
||||
|
||||
runner.onTerminal = (_w, entity) => {
|
||||
// Clean up generator when a leaf reaches terminal by external means
|
||||
generators.delete(entity);
|
||||
runner.onWait = (world, entity, control, dt) => {
|
||||
waits.get(entity)?.(world, entity, control, dt);
|
||||
};
|
||||
|
||||
runner.onCondition = (world, entity, dt) => {
|
||||
const condition = conditions.get(entity);
|
||||
return condition ? condition(world, entity, dt) : false;
|
||||
};
|
||||
|
||||
return runner;
|
||||
|
|
|
|||
381
test/bt.test.ts
381
test/bt.test.ts
|
|
@ -10,15 +10,18 @@ import {
|
|||
ChildOf,
|
||||
TaskRunner,
|
||||
buildTree,
|
||||
leaf,
|
||||
Cancel,
|
||||
action,
|
||||
wait,
|
||||
cycle,
|
||||
whilst,
|
||||
sequential,
|
||||
} from "../src/bt/index";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────
|
||||
function makeLeaf(world: World, parent?: Entity): Entity {
|
||||
function makeWait(world: World, parent?: Entity): Entity {
|
||||
const e = world.spawn();
|
||||
world.add(e, Task, { kind: "leaf" });
|
||||
world.add(e, Task, { kind: "wait" });
|
||||
if (parent) world.relate(e, ChildOf, parent);
|
||||
return e;
|
||||
}
|
||||
|
|
@ -69,9 +72,9 @@ describe("Entity task factories", () => {
|
|||
world,
|
||||
sequential([
|
||||
entity(Label, { value: "sequence metadata" }),
|
||||
leaf(() => calls.push("a")),
|
||||
action(() => calls.push("a")),
|
||||
entity(Label, { value: "between leaves" }),
|
||||
leaf(() => calls.push("b")),
|
||||
action(() => calls.push("b")),
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
@ -90,6 +93,122 @@ describe("Entity task factories", () => {
|
|||
expect(world.has(root, Succeeded)).toBe(true);
|
||||
});
|
||||
|
||||
it("action succeeds immediately when it returns", () => {
|
||||
const world = new World();
|
||||
let receivedDt = 0;
|
||||
let receivedEntity: Entity | undefined;
|
||||
|
||||
const runner = buildTree(
|
||||
world,
|
||||
action((_world, entity, dt) => {
|
||||
receivedEntity = entity;
|
||||
receivedDt = dt;
|
||||
}),
|
||||
);
|
||||
|
||||
runner.schedule(runner.root!);
|
||||
runner.tick(16);
|
||||
|
||||
expect(receivedEntity).toBe(runner.root);
|
||||
expect(receivedDt).toBe(16);
|
||||
expect(world.has(runner.root!, Succeeded)).toBe(true);
|
||||
});
|
||||
|
||||
it("action fails or cancels when it throws", () => {
|
||||
const world = new World();
|
||||
const failed = buildTree(
|
||||
world,
|
||||
action(() => {
|
||||
throw new Error("bad");
|
||||
}),
|
||||
);
|
||||
const cancelled = buildTree(
|
||||
world,
|
||||
action(() => {
|
||||
throw Cancel;
|
||||
}),
|
||||
);
|
||||
|
||||
failed.schedule(failed.root!);
|
||||
failed.tick();
|
||||
cancelled.schedule(cancelled.root!);
|
||||
cancelled.tick();
|
||||
|
||||
expect(world.has(failed.root!, Failed)).toBe(true);
|
||||
expect(world.has(cancelled.root!, Cancelled)).toBe(true);
|
||||
});
|
||||
|
||||
it("wait can complete itself through task control", () => {
|
||||
const world = new World();
|
||||
const runner = buildTree(
|
||||
world,
|
||||
wait((_world, _entity, task) => {
|
||||
task.succeed();
|
||||
}),
|
||||
);
|
||||
|
||||
runner.schedule(runner.root!);
|
||||
runner.tick();
|
||||
|
||||
expect(world.has(runner.root!, Succeeded)).toBe(true);
|
||||
});
|
||||
|
||||
it("wait without a starter remains running", () => {
|
||||
const world = new World();
|
||||
const runner = buildTree(world, wait());
|
||||
|
||||
runner.schedule(runner.root!);
|
||||
runner.tick();
|
||||
|
||||
expect(world.has(runner.root!, Running)).toBe(true);
|
||||
});
|
||||
|
||||
it("whilst loops at tick boundaries until its condition is false", () => {
|
||||
const world = new World();
|
||||
let count = 0;
|
||||
const runner = buildTree(
|
||||
world,
|
||||
whilst(
|
||||
() => count < 3,
|
||||
action(() => {
|
||||
count++;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
runner.schedule(runner.root!);
|
||||
runner.tick();
|
||||
expect(count).toBe(1);
|
||||
expect(world.has(runner.root!, Scheduled)).toBe(true);
|
||||
|
||||
runner.tick();
|
||||
expect(count).toBe(2);
|
||||
|
||||
runner.tick();
|
||||
expect(count).toBe(3);
|
||||
|
||||
runner.tick();
|
||||
expect(world.has(runner.root!, Succeeded)).toBe(true);
|
||||
});
|
||||
|
||||
it("whilst propagates child failure", () => {
|
||||
const world = new World();
|
||||
const runner = buildTree(
|
||||
world,
|
||||
whilst(
|
||||
() => true,
|
||||
action(() => {
|
||||
throw new Error("bad");
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
runner.schedule(runner.root!);
|
||||
runner.tick();
|
||||
|
||||
expect(world.has(runner.root!, Failed)).toBe(true);
|
||||
});
|
||||
|
||||
it("cycle works with component child entities beside its task child", () => {
|
||||
const Label = defineComponent("cycleLabel", { value: "" });
|
||||
const world = new World();
|
||||
|
|
@ -99,7 +218,7 @@ describe("Entity task factories", () => {
|
|||
world,
|
||||
cycle([
|
||||
entity(Label, { value: "cycle metadata" }),
|
||||
leaf(() => {
|
||||
action(() => {
|
||||
calls++;
|
||||
}),
|
||||
]),
|
||||
|
|
@ -112,8 +231,8 @@ describe("Entity task factories", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Leaf tasks ──────────────────────────────────────
|
||||
describe("Leaf tasks", () => {
|
||||
// ── Wait tasks ──────────────────────────────────────
|
||||
describe("Wait tasks", () => {
|
||||
let world: World;
|
||||
let runner: TaskRunner;
|
||||
|
||||
|
|
@ -122,79 +241,79 @@ describe("Leaf tasks", () => {
|
|||
runner = new TaskRunner(world);
|
||||
});
|
||||
|
||||
it("calls onLeaf when a leaf is scheduled and ticked", () => {
|
||||
const leaf = makeLeaf(world);
|
||||
it("calls onWait when a wait task is scheduled and ticked", () => {
|
||||
const action = makeWait(world);
|
||||
const calls: Entity[] = [];
|
||||
|
||||
runner.onLeaf = (_w, e) => calls.push(e);
|
||||
runner.schedule(leaf);
|
||||
runner.onWait = (_w, e) => calls.push(e);
|
||||
runner.schedule(action);
|
||||
runner.tick();
|
||||
|
||||
expect(calls).toEqual([leaf]);
|
||||
expect(calls).toEqual([action]);
|
||||
});
|
||||
|
||||
it("marks leaf as Running after tick", () => {
|
||||
const leaf = makeLeaf(world);
|
||||
runner.schedule(leaf);
|
||||
it("marks wait task as Running after tick", () => {
|
||||
const action = makeWait(world);
|
||||
runner.schedule(action);
|
||||
runner.tick();
|
||||
|
||||
expect(world.has(leaf, Running)).toBe(true);
|
||||
expect(world.has(leaf, Scheduled)).toBe(false);
|
||||
expect(world.has(action, Running)).toBe(true);
|
||||
expect(world.has(action, Scheduled)).toBe(false);
|
||||
});
|
||||
|
||||
it("succeed() marks leaf as Succeeded", () => {
|
||||
const leaf = makeLeaf(world);
|
||||
runner.schedule(leaf);
|
||||
it("succeed() marks wait task as Succeeded", () => {
|
||||
const action = makeWait(world);
|
||||
runner.schedule(action);
|
||||
runner.tick();
|
||||
runner.succeed(leaf);
|
||||
runner.succeed(action);
|
||||
|
||||
expect(world.has(leaf, Succeeded)).toBe(true);
|
||||
expect(world.has(leaf, Running)).toBe(false);
|
||||
expect(world.has(action, Succeeded)).toBe(true);
|
||||
expect(world.has(action, Running)).toBe(false);
|
||||
});
|
||||
|
||||
it("fail() marks leaf as Failed", () => {
|
||||
const leaf = makeLeaf(world);
|
||||
runner.schedule(leaf);
|
||||
it("fail() marks wait task as Failed", () => {
|
||||
const action = makeWait(world);
|
||||
runner.schedule(action);
|
||||
runner.tick();
|
||||
runner.fail(leaf);
|
||||
runner.fail(action);
|
||||
|
||||
expect(world.has(leaf, Failed)).toBe(true);
|
||||
expect(world.has(leaf, Running)).toBe(false);
|
||||
expect(world.has(action, Failed)).toBe(true);
|
||||
expect(world.has(action, Running)).toBe(false);
|
||||
});
|
||||
|
||||
it("cancel() marks leaf as Cancelled", () => {
|
||||
const leaf = makeLeaf(world);
|
||||
runner.schedule(leaf);
|
||||
it("cancel() marks wait task as Cancelled", () => {
|
||||
const action = makeWait(world);
|
||||
runner.schedule(action);
|
||||
runner.tick();
|
||||
runner.cancel(leaf);
|
||||
runner.cancel(action);
|
||||
|
||||
expect(world.has(leaf, Cancelled)).toBe(true);
|
||||
expect(world.has(leaf, Running)).toBe(false);
|
||||
expect(world.has(action, Cancelled)).toBe(true);
|
||||
expect(world.has(action, Running)).toBe(false);
|
||||
});
|
||||
|
||||
it("onTerminal is called when leaf finishes", () => {
|
||||
const leaf = makeLeaf(world);
|
||||
it("onTerminal is called when wait task finishes", () => {
|
||||
const action = makeWait(world);
|
||||
const terminals: { entity: Entity; status: string }[] = [];
|
||||
|
||||
runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s });
|
||||
runner.schedule(leaf);
|
||||
runner.schedule(action);
|
||||
runner.tick();
|
||||
runner.succeed(leaf);
|
||||
runner.succeed(action);
|
||||
|
||||
expect(terminals).toEqual([{ entity: leaf, status: "succeeded" }]);
|
||||
expect(terminals).toEqual([{ entity: action, status: "succeeded" }]);
|
||||
});
|
||||
|
||||
it("reset() clears all status tags", () => {
|
||||
const leaf = makeLeaf(world);
|
||||
runner.schedule(leaf);
|
||||
const action = makeWait(world);
|
||||
runner.schedule(action);
|
||||
runner.tick();
|
||||
runner.succeed(leaf);
|
||||
runner.succeed(action);
|
||||
|
||||
runner.reset(leaf);
|
||||
runner.reset(action);
|
||||
|
||||
expect(world.has(leaf, Succeeded)).toBe(false);
|
||||
expect(world.has(leaf, Running)).toBe(false);
|
||||
expect(world.has(leaf, Scheduled)).toBe(false);
|
||||
expect(world.has(action, Succeeded)).toBe(false);
|
||||
expect(world.has(action, Running)).toBe(false);
|
||||
expect(world.has(action, Scheduled)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -210,12 +329,12 @@ describe("Sequential tasks", () => {
|
|||
|
||||
it("runs children one at a time in order", () => {
|
||||
const seq = makeSequential(world);
|
||||
const a = makeLeaf(world, seq);
|
||||
const b = makeLeaf(world, seq);
|
||||
const c = makeLeaf(world, seq);
|
||||
const a = makeWait(world, seq);
|
||||
const b = makeWait(world, seq);
|
||||
const c = makeWait(world, seq);
|
||||
|
||||
const leafCalls: Entity[] = [];
|
||||
runner.onLeaf = (_w, e) => leafCalls.push(e);
|
||||
runner.onWait = (_w, e) => leafCalls.push(e);
|
||||
|
||||
runner.schedule(seq);
|
||||
runner.tick();
|
||||
|
|
@ -236,8 +355,8 @@ describe("Sequential tasks", () => {
|
|||
|
||||
it("fails immediately when a child fails", () => {
|
||||
const seq = makeSequential(world);
|
||||
const a = makeLeaf(world, seq);
|
||||
const b = makeLeaf(world, seq);
|
||||
const a = makeWait(world, seq);
|
||||
const b = makeWait(world, seq);
|
||||
|
||||
runner.schedule(seq);
|
||||
runner.tick(); // schedules a
|
||||
|
|
@ -254,8 +373,8 @@ describe("Sequential tasks", () => {
|
|||
|
||||
it("succeeds when all children succeed", () => {
|
||||
const seq = makeSequential(world);
|
||||
const a = makeLeaf(world, seq);
|
||||
const b = makeLeaf(world, seq);
|
||||
const a = makeWait(world, seq);
|
||||
const b = makeWait(world, seq);
|
||||
|
||||
runner.schedule(seq);
|
||||
runner.tick(); // schedules a
|
||||
|
|
@ -272,20 +391,20 @@ describe("Sequential tasks", () => {
|
|||
it("propagates terminal to grandparent", () => {
|
||||
const root = makeSequential(world);
|
||||
const child = makeSequential(world, root);
|
||||
const leaf = makeLeaf(world, child);
|
||||
const action = makeWait(world, child);
|
||||
|
||||
const terminals: Entity[] = [];
|
||||
runner.onTerminal = (_w, e) => terminals.push(e);
|
||||
|
||||
runner.schedule(root);
|
||||
runner.tick(); // schedules child
|
||||
runner.tick(); // schedules leaf
|
||||
runner.tick(); // runs leaf
|
||||
runner.succeed(leaf);
|
||||
runner.tick(); // schedules action
|
||||
runner.tick(); // runs action
|
||||
runner.succeed(action);
|
||||
runner.tick(); // child succeeds
|
||||
runner.tick(); // root succeeds
|
||||
|
||||
expect(terminals).toEqual([leaf, child, root]);
|
||||
expect(terminals).toEqual([action, child, root]);
|
||||
expect(world.has(root, Succeeded)).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -311,9 +430,9 @@ describe("Parallel tasks", () => {
|
|||
|
||||
it("starts all children at once", () => {
|
||||
const par = makeParallel(world);
|
||||
const a = makeLeaf(world, par);
|
||||
const b = makeLeaf(world, par);
|
||||
const c = makeLeaf(world, par);
|
||||
const a = makeWait(world, par);
|
||||
const b = makeWait(world, par);
|
||||
const c = makeWait(world, par);
|
||||
|
||||
runner.schedule(par);
|
||||
runner.tick();
|
||||
|
|
@ -325,8 +444,8 @@ describe("Parallel tasks", () => {
|
|||
|
||||
it("succeeds when all children succeed", () => {
|
||||
const par = makeParallel(world);
|
||||
const a = makeLeaf(world, par);
|
||||
const b = makeLeaf(world, par);
|
||||
const a = makeWait(world, par);
|
||||
const b = makeWait(world, par);
|
||||
|
||||
runner.schedule(par);
|
||||
runner.tick(); // schedules both
|
||||
|
|
@ -355,8 +474,8 @@ describe("Parallel tasks", () => {
|
|||
|
||||
it("fails immediately when any child fails", () => {
|
||||
const par = makeParallel(world);
|
||||
const a = makeLeaf(world, par);
|
||||
const b = makeLeaf(world, par);
|
||||
const a = makeWait(world, par);
|
||||
const b = makeWait(world, par);
|
||||
|
||||
runner.schedule(par);
|
||||
runner.tick(); // schedules both
|
||||
|
|
@ -391,8 +510,8 @@ describe("Random tasks", () => {
|
|||
|
||||
it("picks one child and succeeds/fails with it", () => {
|
||||
const rand = makeRandom(world);
|
||||
const a = makeLeaf(world, rand);
|
||||
const b = makeLeaf(world, rand);
|
||||
const a = makeWait(world, rand);
|
||||
const b = makeWait(world, rand);
|
||||
|
||||
runner.schedule(rand);
|
||||
runner.tick();
|
||||
|
|
@ -409,7 +528,7 @@ describe("Random tasks", () => {
|
|||
|
||||
it("fails when picked child fails", () => {
|
||||
const rand = makeRandom(world);
|
||||
const a = makeLeaf(world, rand);
|
||||
const a = makeWait(world, rand);
|
||||
|
||||
runner.schedule(rand);
|
||||
runner.tick(); // schedules a (only child)
|
||||
|
|
@ -444,55 +563,55 @@ describe("Cycle tasks", () => {
|
|||
|
||||
it("re-runs child after it succeeds", () => {
|
||||
const cyc = makeCycle(world);
|
||||
const leaf = makeLeaf(world, cyc);
|
||||
const action = makeWait(world, cyc);
|
||||
|
||||
let leafCount = 0;
|
||||
runner.onLeaf = () => leafCount++;
|
||||
let waitCount = 0;
|
||||
runner.onWait = () => waitCount++;
|
||||
|
||||
runner.schedule(cyc);
|
||||
|
||||
runner.tick();
|
||||
expect(leafCount).toBe(1);
|
||||
runner.succeed(leaf);
|
||||
expect(waitCount).toBe(1);
|
||||
runner.succeed(action);
|
||||
|
||||
// Completion schedules the next cycle at a tick boundary.
|
||||
expect(world.has(leaf, Scheduled)).toBe(true);
|
||||
// Completion schedules the cycle node at a tick boundary.
|
||||
expect(world.has(cyc, Scheduled)).toBe(true);
|
||||
runner.tick();
|
||||
expect(leafCount).toBe(2);
|
||||
runner.succeed(leaf);
|
||||
expect(waitCount).toBe(2);
|
||||
runner.succeed(action);
|
||||
|
||||
runner.tick();
|
||||
expect(leafCount).toBe(3);
|
||||
expect(waitCount).toBe(3);
|
||||
});
|
||||
|
||||
it("re-runs child after it fails", () => {
|
||||
const cyc = makeCycle(world);
|
||||
const leaf = makeLeaf(world, cyc);
|
||||
const action = makeWait(world, cyc);
|
||||
|
||||
let leafCount = 0;
|
||||
runner.onLeaf = () => leafCount++;
|
||||
let waitCount = 0;
|
||||
runner.onWait = () => waitCount++;
|
||||
|
||||
runner.schedule(cyc);
|
||||
runner.tick();
|
||||
runner.fail(leaf);
|
||||
runner.fail(action);
|
||||
|
||||
expect(world.has(leaf, Scheduled)).toBe(true);
|
||||
expect(world.has(cyc, Scheduled)).toBe(true);
|
||||
runner.tick();
|
||||
expect(leafCount).toBe(2);
|
||||
expect(waitCount).toBe(2);
|
||||
});
|
||||
|
||||
it("never terminates on its own", () => {
|
||||
const cyc = makeCycle(world);
|
||||
const leaf = makeLeaf(world, cyc);
|
||||
const action = makeWait(world, cyc);
|
||||
|
||||
runner.schedule(cyc);
|
||||
runner.tick();
|
||||
runner.succeed(leaf);
|
||||
runner.succeed(action);
|
||||
|
||||
// After many cycles, cycle is still not terminal
|
||||
for (let i = 0; i < 5; i++) {
|
||||
runner.tick();
|
||||
runner.succeed(leaf);
|
||||
runner.succeed(action);
|
||||
}
|
||||
|
||||
expect(world.has(cyc, Succeeded)).toBe(false);
|
||||
|
|
@ -502,7 +621,7 @@ describe("Cycle tasks", () => {
|
|||
|
||||
it("can be cancelled", () => {
|
||||
const cyc = makeCycle(world);
|
||||
const leaf = makeLeaf(world, cyc);
|
||||
const action = makeWait(world, cyc);
|
||||
|
||||
runner.schedule(cyc);
|
||||
runner.tick();
|
||||
|
|
@ -510,7 +629,7 @@ describe("Cycle tasks", () => {
|
|||
runner.cancel(cyc);
|
||||
|
||||
expect(world.has(cyc, Cancelled)).toBe(true);
|
||||
expect(world.has(leaf, Cancelled)).toBe(true);
|
||||
expect(world.has(action, Cancelled)).toBe(true);
|
||||
});
|
||||
|
||||
it("empty cycle does nothing", () => {
|
||||
|
|
@ -527,8 +646,8 @@ describe("Cycle tasks", () => {
|
|||
it("cycle inside sequential advances parent when cancelled", () => {
|
||||
const seq = makeSequential(world);
|
||||
const cyc = makeCycle(world, seq);
|
||||
const leaf = makeLeaf(world, cyc);
|
||||
const after = makeLeaf(world, seq);
|
||||
const action = makeWait(world, cyc);
|
||||
const after = makeWait(world, seq);
|
||||
|
||||
runner.schedule(seq);
|
||||
runner.tick();
|
||||
|
|
@ -553,8 +672,8 @@ describe("Selector tasks", () => {
|
|||
|
||||
it("succeeds on first child that succeeds", () => {
|
||||
const sel = makeSelector(world);
|
||||
const a = makeLeaf(world, sel);
|
||||
const b = makeLeaf(world, sel);
|
||||
const a = makeWait(world, sel);
|
||||
const b = makeWait(world, sel);
|
||||
|
||||
runner.schedule(sel);
|
||||
runner.tick(); // schedules a
|
||||
|
|
@ -571,9 +690,9 @@ describe("Selector tasks", () => {
|
|||
|
||||
it("tries next child when previous fails", () => {
|
||||
const sel = makeSelector(world);
|
||||
const a = makeLeaf(world, sel);
|
||||
const b = makeLeaf(world, sel);
|
||||
const c = makeLeaf(world, sel);
|
||||
const a = makeWait(world, sel);
|
||||
const b = makeWait(world, sel);
|
||||
const c = makeWait(world, sel);
|
||||
|
||||
runner.schedule(sel);
|
||||
runner.tick(); // schedules a
|
||||
|
|
@ -591,8 +710,8 @@ describe("Selector tasks", () => {
|
|||
|
||||
it("fails when all children fail", () => {
|
||||
const sel = makeSelector(world);
|
||||
const a = makeLeaf(world, sel);
|
||||
const b = makeLeaf(world, sel);
|
||||
const a = makeWait(world, sel);
|
||||
const b = makeWait(world, sel);
|
||||
|
||||
runner.schedule(sel);
|
||||
runner.tick(); // schedules a
|
||||
|
|
@ -617,8 +736,8 @@ describe("Selector tasks", () => {
|
|||
|
||||
it("skips cancelled children and continues", () => {
|
||||
const sel = makeSelector(world);
|
||||
const a = makeLeaf(world, sel);
|
||||
const b = makeLeaf(world, sel);
|
||||
const a = makeWait(world, sel);
|
||||
const b = makeWait(world, sel);
|
||||
|
||||
runner.schedule(sel);
|
||||
runner.tick(); // schedules a
|
||||
|
|
@ -642,8 +761,8 @@ describe("Cancel", () => {
|
|||
it("cancels all descendants", () => {
|
||||
const root = makeSequential(world);
|
||||
const child = makeParallel(world, root);
|
||||
const a = makeLeaf(world, child);
|
||||
const b = makeLeaf(world, child);
|
||||
const a = makeWait(world, child);
|
||||
const b = makeWait(world, child);
|
||||
|
||||
runner.schedule(root);
|
||||
runner.tick(); // schedules child
|
||||
|
|
@ -662,17 +781,17 @@ describe("Cancel", () => {
|
|||
it("cancel propagates to parent", () => {
|
||||
const root = makeSequential(world);
|
||||
const child = makeSequential(world, root);
|
||||
const leaf = makeLeaf(world, child);
|
||||
const action = makeWait(world, child);
|
||||
|
||||
runner.schedule(root);
|
||||
runner.tick(); // schedules child
|
||||
runner.tick(); // schedules leaf
|
||||
runner.tick(); // runs leaf
|
||||
runner.tick(); // schedules action
|
||||
runner.tick(); // runs action
|
||||
|
||||
runner.cancel(leaf);
|
||||
runner.cancel(action);
|
||||
|
||||
// leaf cancelled → child re-scheduled → child sees leaf cancelled → child cancelled
|
||||
runner.tick(); // child processes cancelled leaf
|
||||
// action cancelled → child re-scheduled → child sees action cancelled → child cancelled
|
||||
runner.tick(); // child processes cancelled action
|
||||
expect(world.has(child, Cancelled)).toBe(true);
|
||||
|
||||
// child cancelled → root re-scheduled → root sees child cancelled → root cancelled
|
||||
|
|
@ -681,8 +800,8 @@ describe("Cancel", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Multi-frame leaves ──────────────────────────────
|
||||
describe("Multi-frame leaves", () => {
|
||||
// ── Multi-frame wait tasks ──────────────────────────────
|
||||
describe("Multi-frame wait tasks", () => {
|
||||
let world: World;
|
||||
let runner: TaskRunner;
|
||||
|
||||
|
|
@ -691,28 +810,28 @@ describe("Multi-frame leaves", () => {
|
|||
runner = new TaskRunner(world);
|
||||
});
|
||||
|
||||
it("leaf stays Running across ticks until explicitly finished", () => {
|
||||
const leaf = makeLeaf(world);
|
||||
it("wait task stays Running across ticks until explicitly finished", () => {
|
||||
const action = makeWait(world);
|
||||
|
||||
runner.schedule(leaf);
|
||||
runner.schedule(action);
|
||||
runner.tick();
|
||||
|
||||
expect(world.has(leaf, Running)).toBe(true);
|
||||
expect(world.has(action, Running)).toBe(true);
|
||||
|
||||
// Tick again — leaf is Running, not Scheduled, so nothing happens
|
||||
// Tick again — action is Running, not Scheduled, so nothing happens
|
||||
runner.tick();
|
||||
expect(world.has(leaf, Running)).toBe(true);
|
||||
expect(world.has(action, Running)).toBe(true);
|
||||
|
||||
// External system finishes it
|
||||
runner.succeed(leaf);
|
||||
expect(world.has(leaf, Succeeded)).toBe(true);
|
||||
expect(world.has(leaf, Running)).toBe(false);
|
||||
runner.succeed(action);
|
||||
expect(world.has(action, Succeeded)).toBe(true);
|
||||
expect(world.has(action, Running)).toBe(false);
|
||||
});
|
||||
|
||||
it("sequential waits for multi-frame leaf before advancing", () => {
|
||||
it("sequential waits for multi-frame wait before advancing", () => {
|
||||
const seq = makeSequential(world);
|
||||
const a = makeLeaf(world, seq);
|
||||
const b = makeLeaf(world, seq);
|
||||
const a = makeWait(world, seq);
|
||||
const b = makeWait(world, seq);
|
||||
|
||||
runner.schedule(seq);
|
||||
runner.tick(); // schedules a
|
||||
|
|
@ -754,11 +873,11 @@ describe("Edge cases", () => {
|
|||
it("tick is a no-op when nothing is scheduled", () => {
|
||||
const world = new World();
|
||||
const runner = new TaskRunner(world);
|
||||
const leaf = makeLeaf(world);
|
||||
const action = makeWait(world);
|
||||
|
||||
// Leaf exists but is not Scheduled
|
||||
// Wait task exists but is not Scheduled
|
||||
expect(() => runner.tick()).not.toThrow();
|
||||
expect(world.has(leaf, Running)).toBe(false);
|
||||
expect(world.has(action, Running)).toBe(false);
|
||||
});
|
||||
|
||||
it("deeply nested tree works correctly", () => {
|
||||
|
|
@ -767,14 +886,14 @@ describe("Edge cases", () => {
|
|||
|
||||
const root = makeSequential(world);
|
||||
const mid = makeSequential(world, root);
|
||||
const leaf = makeLeaf(world, mid);
|
||||
const action = makeWait(world, mid);
|
||||
|
||||
runner.schedule(root);
|
||||
|
||||
runner.tick();
|
||||
expect(world.has(leaf, Running)).toBe(true);
|
||||
expect(world.has(action, Running)).toBe(true);
|
||||
|
||||
runner.succeed(leaf);
|
||||
runner.succeed(action);
|
||||
expect(world.has(mid, Succeeded)).toBe(true);
|
||||
expect(world.has(root, Succeeded)).toBe(true);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue