feat: add behavior tree implementation

Introduces a `TaskRunner` for executing behavior trees within the
ECS world. Supports leaf, sequential, parallel, and random task
kinds, along with status propagation (succeeded, failed, cancelled).
This commit is contained in:
hypercross 2026-06-01 22:52:10 +08:00
parent 2fe9203be9
commit 4e37e03d3f
6 changed files with 890 additions and 1 deletions

View File

@ -16,6 +16,11 @@
"types": "./dist/commands/index.d.ts", "types": "./dist/commands/index.d.ts",
"import": "./dist/commands/index.js", "import": "./dist/commands/index.js",
"require": "./dist/commands/index.cjs" "require": "./dist/commands/index.cjs"
},
"./bt": {
"types": "./dist/bt/index.d.ts",
"import": "./dist/bt/index.js",
"require": "./dist/bt/index.cjs"
} }
}, },
"files": [ "files": [

14
src/bt/index.ts Normal file
View File

@ -0,0 +1,14 @@
export {
Task,
Scheduled,
Running,
Succeeded,
Failed,
Cancelled,
TERMINAL_TAGS,
ChildOf,
} from "./task";
export type { TaskKind } from "./task";
export { TaskRunner } from "./runner";
export type { LeafHandler, TerminalHandler } from "./runner";

287
src/bt/runner.ts Normal file
View File

@ -0,0 +1,287 @@
import type { World, Entity } from "../index";
import { query } from "../query";
import {
Task,
Scheduled,
Running,
Succeeded,
Failed,
Cancelled,
TERMINAL_TAGS,
ChildOf,
} from "./task";
// ── Types ─────────────────────────────────────────────
/** Callback invoked for each leaf task that becomes Scheduled. */
export type LeafHandler = (world: World, entity: Entity) => void;
/** Callback invoked when a task reaches a terminal status. */
export type TerminalHandler = (
world: World,
entity: Entity,
status: "succeeded" | "failed" | "cancelled",
) => void;
// ── Helpers ───────────────────────────────────────────
function terminalStatus(
world: World,
entity: Entity,
): "succeeded" | "failed" | "cancelled" | null {
if (world.has(entity, Succeeded)) return "succeeded";
if (world.has(entity, Failed)) return "failed";
if (world.has(entity, Cancelled)) return "cancelled";
return null;
}
function isTerminal(world: World, entity: Entity): boolean {
return terminalStatus(world, entity) !== null;
}
function clearStatus(world: World, entity: Entity): void {
for (const tag of TERMINAL_TAGS) {
if (world.has(entity, tag)) world.remove(entity, tag);
}
if (world.has(entity, Running)) world.remove(entity, Running);
}
function childrenOf(world: World, parent: Entity): Entity[] {
return world.getRelatedTo(parent, ChildOf);
}
function parentOf(world: World, child: Entity): Entity | null {
return world.getRelated(child, ChildOf) ?? null;
}
// ── TaskRunner ────────────────────────────────────────
/**
* Push-based behaviour-tree runner.
*
* Only tasks tagged with `Scheduled` are processed each tick.
* When a task finishes, it notifies its parent, which may schedule
* the next child (sequential), aggregate results (parallel), or
* propagate upward.
*
* 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();
* ```
*/
export class TaskRunner {
private _world: World;
/** Called when a leaf task becomes Scheduled. */
onLeaf: LeafHandler = () => {};
/** Called when any task reaches a terminal status. */
onTerminal: TerminalHandler = () => {};
constructor(world: World) {
this._world = world;
}
// ── Public API ────────────────────────────────────
/**
* Process all Scheduled tasks.
*
* Call once per frame. Only entities with `Scheduled` are touched.
*/
tick(): void {
const scheduled = [...this._world.query(query(Task, Scheduled))];
for (const entity of scheduled) {
this._world.remove(entity, Scheduled);
this._execute(entity);
}
}
/** Mark a leaf task as succeeded and propagate upward. */
succeed(entity: Entity): void {
this._finish(entity, Succeeded);
}
/** Mark a leaf task as failed and propagate upward. */
fail(entity: Entity): void {
this._finish(entity, Failed);
}
/** Cancel a task and all its descendants. */
cancel(entity: Entity): void {
this._cancelTree(entity);
}
/** Schedule a task for execution next tick. */
schedule(entity: Entity): void {
if (this._world.has(entity, Task)) {
this._world.add(entity, Scheduled);
}
}
/** Reset a task to idle (removes all status tags). */
reset(entity: Entity): void {
clearStatus(this._world, entity);
this._world.remove(entity, Scheduled);
}
// ── Internal execution ────────────────────────────
private _execute(entity: Entity): void {
const t = this._world.get(entity, Task);
switch (t.kind) {
case "leaf":
this._executeLeaf(entity);
break;
case "sequential":
this._executeSequential(entity);
break;
case "parallel":
this._executeParallel(entity);
break;
case "random":
this._executeRandom(entity);
break;
}
}
private _executeLeaf(entity: Entity): void {
this._world.add(entity, Running);
this.onLeaf(this._world, entity);
}
private _executeSequential(entity: Entity): void {
const children = childrenOf(this._world, entity);
// 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") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
// succeeded — continue to next child
continue;
}
// Found a child that hasn't run yet — schedule it
this._world.add(child, Scheduled);
return;
}
// All children succeeded
this._finish(entity, Succeeded);
}
private _executeParallel(entity: Entity): void {
const children = childrenOf(this._world, entity);
let allDone = true;
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
// succeeded — this child is done
continue;
}
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) {
this._finish(entity, Succeeded);
}
}
private _executeRandom(entity: Entity): void {
const children = childrenOf(this._world, entity);
// Check if any child already reached a terminal
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
this._finish(
entity,
status === "succeeded"
? Succeeded
: status === "cancelled"
? Cancelled
: Failed,
);
return;
}
}
// Pick a random child that isn't already running
const eligible = children.filter(
(c) => !this._world.has(c, Running) && !this._world.has(c, Scheduled),
);
if (eligible.length > 0) {
const pick = eligible[Math.floor(Math.random() * eligible.length)];
this._world.add(pick, Scheduled);
}
// If no eligible children (all running), wait for one to finish
}
// ── Completion propagation ────────────────────────
private _finish(
entity: Entity,
tag: typeof Succeeded | typeof Failed | typeof Cancelled,
): void {
if (!this._world.has(entity, Task)) return;
clearStatus(this._world, entity);
this._world.add(entity, tag);
const status = terminalStatus(this._world, entity)!;
this.onTerminal(this._world, entity, status);
// Notify parent
const parent = parentOf(this._world, entity);
if (parent) {
this._world.add(parent, Scheduled);
}
}
private _cancelTree(entity: Entity): void {
if (!this._world.has(entity, Task)) return;
// Cancel children first
for (const child of childrenOf(this._world, entity)) {
this._cancelTree(child);
}
// Cancel this node
clearStatus(this._world, entity);
this._world.add(entity, Cancelled);
this.onTerminal(this._world, entity, "cancelled");
// Notify parent
const parent = parentOf(this._world, entity);
if (parent) {
this._world.add(parent, Scheduled);
}
}
}

44
src/bt/task.ts Normal file
View File

@ -0,0 +1,44 @@
import { defineComponent } from "../component";
import { defineRelationship } from "../relationship";
// ── 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.
* - `"sequential"` runs children one at a time, left to right.
* Succeeds when all children succeed; fails when any child fails.
* - `"parallel"` schedules all children at once.
* Succeeds when all children succeed; fails when any child fails.
* - `"random"` picks one child at random each time it's scheduled.
* Succeeds/fails with that child's result.
*/
export const Task = defineComponent("task", {
kind: "leaf" as "leaf" | "sequential" | "parallel" | "random",
});
export type TaskKind = typeof Task.type["kind"];
// ── Status tags (zero-size — presence is the signal) ──
/** A task that should be executed this tick. */
export const Scheduled = defineComponent("scheduled", {});
/** A task that is currently executing (multi-frame leaves). */
export const Running = defineComponent("running", {});
/** The task completed successfully. */
export const Succeeded = defineComponent("succeeded", {});
/** The task failed. */
export const Failed = defineComponent("failed", {});
/** The task was cancelled externally. */
export const Cancelled = defineComponent("cancelled", {});
/** All terminal status tags. */
export const TERMINAL_TAGS = [Succeeded, Failed, Cancelled] as const;
// ── Relationship ──────────────────────────────────────
/** Parent → child edge in the task tree. */
export const ChildOf = defineRelationship("taskChild");

539
test/bt.test.ts Normal file
View File

@ -0,0 +1,539 @@
import { describe, it, expect, beforeEach } from "vitest";
import { World, type Entity } from "../src/index";
import {
Task,
Scheduled,
Running,
Succeeded,
Failed,
Cancelled,
ChildOf,
TaskRunner,
} from "../src/bt/index";
// ── Helpers ─────────────────────────────────────────
function makeLeaf(world: World, parent?: Entity): Entity {
const e = world.spawn();
world.add(e, Task, { kind: "leaf" });
if (parent) world.relate(e, ChildOf, parent);
return e;
}
function makeSequential(world: World, parent?: Entity): Entity {
const e = world.spawn();
world.add(e, Task, { kind: "sequential" });
if (parent) world.relate(e, ChildOf, parent);
return e;
}
function makeParallel(world: World, parent?: Entity): Entity {
const e = world.spawn();
world.add(e, Task, { kind: "parallel" });
if (parent) world.relate(e, ChildOf, parent);
return e;
}
function makeRandom(world: World, parent?: Entity): Entity {
const e = world.spawn();
world.add(e, Task, { kind: "random" });
if (parent) world.relate(e, ChildOf, parent);
return e;
}
// ── Leaf tasks ──────────────────────────────────────
describe("Leaf tasks", () => {
let world: World;
let runner: TaskRunner;
beforeEach(() => {
world = new World();
runner = new TaskRunner(world);
});
it("calls onLeaf when a leaf is scheduled and ticked", () => {
const leaf = makeLeaf(world);
const calls: Entity[] = [];
runner.onLeaf = (_w, e) => calls.push(e);
runner.schedule(leaf);
runner.tick();
expect(calls).toEqual([leaf]);
});
it("marks leaf as Running after tick", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
runner.tick();
expect(world.has(leaf, Running)).toBe(true);
expect(world.has(leaf, Scheduled)).toBe(false);
});
it("succeed() marks leaf as Succeeded", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
runner.tick();
runner.succeed(leaf);
expect(world.has(leaf, Succeeded)).toBe(true);
expect(world.has(leaf, Running)).toBe(false);
});
it("fail() marks leaf as Failed", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
runner.tick();
runner.fail(leaf);
expect(world.has(leaf, Failed)).toBe(true);
expect(world.has(leaf, Running)).toBe(false);
});
it("cancel() marks leaf as Cancelled", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
runner.tick();
runner.cancel(leaf);
expect(world.has(leaf, Cancelled)).toBe(true);
expect(world.has(leaf, Running)).toBe(false);
});
it("onTerminal is called when leaf finishes", () => {
const leaf = makeLeaf(world);
const terminals: { entity: Entity; status: string }[] = [];
runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s });
runner.schedule(leaf);
runner.tick();
runner.succeed(leaf);
expect(terminals).toEqual([{ entity: leaf, status: "succeeded" }]);
});
it("reset() clears all status tags", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
runner.tick();
runner.succeed(leaf);
runner.reset(leaf);
expect(world.has(leaf, Succeeded)).toBe(false);
expect(world.has(leaf, Running)).toBe(false);
expect(world.has(leaf, Scheduled)).toBe(false);
});
});
// ── Sequential ──────────────────────────────────────
describe("Sequential tasks", () => {
let world: World;
let runner: TaskRunner;
beforeEach(() => {
world = new World();
runner = new TaskRunner(world);
});
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 leafCalls: Entity[] = [];
runner.onLeaf = (_w, e) => leafCalls.push(e);
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();
expect(world.has(seq, Succeeded)).toBe(true);
});
it("fails immediately when a child fails", () => {
const seq = makeSequential(world);
const a = makeLeaf(world, seq);
const b = makeLeaf(world, seq);
runner.schedule(seq);
runner.tick(); // schedules a
runner.tick(); // runs a
runner.fail(a);
// parent re-scheduled
runner.tick(); // sees a failed → seq fails
expect(world.has(seq, Failed)).toBe(true);
// b was never touched
expect(world.has(b, Scheduled)).toBe(false);
expect(world.has(b, Running)).toBe(false);
});
it("succeeds when all children succeed", () => {
const seq = makeSequential(world);
const a = makeLeaf(world, seq);
const b = makeLeaf(world, seq);
runner.schedule(seq);
runner.tick(); // schedules a
runner.tick(); // runs a
runner.succeed(a);
runner.tick(); // schedules b
runner.tick(); // runs b
runner.succeed(b);
runner.tick(); // seq succeeds
expect(world.has(seq, Succeeded)).toBe(true);
});
it("propagates terminal to grandparent", () => {
const root = makeSequential(world);
const child = makeSequential(world, root);
const leaf = makeLeaf(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(); // child succeeds
runner.tick(); // root succeeds
expect(terminals).toEqual([leaf, child, root]);
expect(world.has(root, Succeeded)).toBe(true);
});
it("empty sequential succeeds immediately", () => {
const seq = makeSequential(world);
runner.schedule(seq);
runner.tick();
expect(world.has(seq, Succeeded)).toBe(true);
});
});
// ── Parallel ────────────────────────────────────────
describe("Parallel tasks", () => {
let world: World;
let runner: TaskRunner;
beforeEach(() => {
world = new World();
runner = new TaskRunner(world);
});
it("schedules all children at once", () => {
const par = makeParallel(world);
const a = makeLeaf(world, par);
const b = makeLeaf(world, par);
const c = makeLeaf(world, par);
runner.schedule(par);
runner.tick();
expect(world.has(a, Scheduled)).toBe(true);
expect(world.has(b, Scheduled)).toBe(true);
expect(world.has(c, Scheduled)).toBe(true);
});
it("succeeds when all children succeed", () => {
const par = makeParallel(world);
const a = makeLeaf(world, par);
const b = makeLeaf(world, par);
runner.schedule(par);
runner.tick(); // schedules both
runner.tick(); // runs a
runner.succeed(a);
// par is re-scheduled, but b is still running
runner.tick(); // par sees a done, b still needs scheduling
// b should be scheduled (it was removed from Scheduled when ticked)
// Actually: b was scheduled in first tick, then ticked in second tick
// Let me trace more carefully...
// After first tick: a=Scheduled, b=Scheduled, par=no status
// Second tick processes Scheduled: a and b both get ticked
// a runs, b runs. Both are Running.
// We succeed a → par gets Scheduled
// Third tick: par sees a=Succeeded, b=Running → waits
// We need to succeed b too
runner.succeed(b);
// par gets Scheduled again
runner.tick(); // par sees both done → succeeds
expect(world.has(par, Succeeded)).toBe(true);
});
it("fails immediately when any child fails", () => {
const par = makeParallel(world);
const a = makeLeaf(world, par);
const b = makeLeaf(world, par);
runner.schedule(par);
runner.tick(); // schedules both
runner.tick(); // runs a
runner.tick(); // runs b
runner.fail(a);
// par re-scheduled
runner.tick(); // sees a failed → par fails
expect(world.has(par, Failed)).toBe(true);
});
it("empty parallel succeeds immediately", () => {
const par = makeParallel(world);
runner.schedule(par);
runner.tick();
expect(world.has(par, Succeeded)).toBe(true);
});
});
// ── Random ──────────────────────────────────────────
describe("Random tasks", () => {
let world: World;
let runner: TaskRunner;
beforeEach(() => {
world = new World();
runner = new TaskRunner(world);
});
it("picks one child and succeeds/fails with it", () => {
const rand = makeRandom(world);
const a = makeLeaf(world, rand);
const b = makeLeaf(world, rand);
runner.schedule(rand);
runner.tick();
// Exactly one child should be scheduled
const scheduled = [world.has(a, Scheduled), world.has(b, Scheduled)];
expect(scheduled.filter(Boolean)).toHaveLength(1);
const picked = world.has(a, Scheduled) ? a : b;
runner.tick(); // runs picked leaf
runner.succeed(picked);
runner.tick(); // random sees child done → succeeds
expect(world.has(rand, Succeeded)).toBe(true);
});
it("fails when picked child fails", () => {
const rand = makeRandom(world);
const a = makeLeaf(world, rand);
runner.schedule(rand);
runner.tick(); // schedules a (only child)
runner.tick(); // runs a
runner.fail(a);
runner.tick(); // random fails
expect(world.has(rand, Failed)).toBe(true);
});
it("empty random does nothing (no children to pick)", () => {
const rand = makeRandom(world);
runner.schedule(rand);
runner.tick();
// No children, so no status change
expect(world.has(rand, Succeeded)).toBe(false);
expect(world.has(rand, Failed)).toBe(false);
});
});
// ── Cancel ──────────────────────────────────────────
describe("Cancel", () => {
let world: World;
let runner: TaskRunner;
beforeEach(() => {
world = new World();
runner = new TaskRunner(world);
});
it("cancels all descendants", () => {
const root = makeSequential(world);
const child = makeParallel(world, root);
const a = makeLeaf(world, child);
const b = makeLeaf(world, child);
runner.schedule(root);
runner.tick(); // schedules child
runner.tick(); // schedules a, b
runner.tick(); // runs a
runner.tick(); // runs b
runner.cancel(root);
expect(world.has(root, Cancelled)).toBe(true);
expect(world.has(child, Cancelled)).toBe(true);
expect(world.has(a, Cancelled)).toBe(true);
expect(world.has(b, Cancelled)).toBe(true);
});
it("cancel propagates to parent", () => {
const root = makeSequential(world);
const child = makeSequential(world, root);
const leaf = makeLeaf(world, child);
runner.schedule(root);
runner.tick(); // schedules child
runner.tick(); // schedules leaf
runner.tick(); // runs leaf
runner.cancel(leaf);
// leaf cancelled → child re-scheduled → child sees leaf cancelled → child cancelled
runner.tick(); // child processes cancelled leaf
expect(world.has(child, Cancelled)).toBe(true);
// child cancelled → root re-scheduled → root sees child cancelled → root cancelled
runner.tick();
expect(world.has(root, Cancelled)).toBe(true);
});
});
// ── Multi-frame leaves ──────────────────────────────
describe("Multi-frame leaves", () => {
let world: World;
let runner: TaskRunner;
beforeEach(() => {
world = new World();
runner = new TaskRunner(world);
});
it("leaf stays Running across ticks until explicitly finished", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
runner.tick();
expect(world.has(leaf, Running)).toBe(true);
// Tick again — leaf is Running, not Scheduled, so nothing happens
runner.tick();
expect(world.has(leaf, Running)).toBe(true);
// External system finishes it
runner.succeed(leaf);
expect(world.has(leaf, Succeeded)).toBe(true);
expect(world.has(leaf, Running)).toBe(false);
});
it("sequential waits for multi-frame leaf before advancing", () => {
const seq = makeSequential(world);
const a = makeLeaf(world, seq);
const b = makeLeaf(world, seq);
runner.schedule(seq);
runner.tick(); // schedules a
runner.tick(); // runs a → Running
// Tick several times — seq should not advance
runner.tick();
runner.tick();
expect(world.has(b, Scheduled)).toBe(false);
expect(world.has(b, Running)).toBe(false);
// Finish a
runner.succeed(a);
runner.tick(); // seq schedules b
expect(world.has(b, Scheduled)).toBe(true);
});
});
// ── Edge cases ──────────────────────────────────────
describe("Edge cases", () => {
it("schedule() is a no-op on non-task entities", () => {
const world = new World();
const runner = new TaskRunner(world);
const e = world.spawn();
runner.schedule(e);
expect(world.has(e, Scheduled)).toBe(false);
});
it("succeed/fail/cancel are no-ops on non-task entities", () => {
const world = new World();
const runner = new TaskRunner(world);
const e = world.spawn();
expect(() => runner.succeed(e)).not.toThrow();
expect(() => runner.fail(e)).not.toThrow();
expect(() => runner.cancel(e)).not.toThrow();
});
it("tick is a no-op when nothing is scheduled", () => {
const world = new World();
const runner = new TaskRunner(world);
const leaf = makeLeaf(world);
// Leaf exists but is not Scheduled
expect(() => runner.tick()).not.toThrow();
expect(world.has(leaf, Running)).toBe(false);
});
it("deeply nested tree works correctly", () => {
const world = new World();
const runner = new TaskRunner(world);
const root = makeSequential(world);
const mid = makeSequential(world, root);
const leaf = makeLeaf(world, mid);
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();
expect(world.has(leaf, Running)).toBe(true);
runner.succeed(leaf);
// leaf done → mid scheduled
runner.tick(); // mid sees leaf done → mid succeeds
expect(world.has(mid, Succeeded)).toBe(true);
runner.tick(); // root sees mid done → root succeeds
expect(world.has(root, Succeeded)).toBe(true);
});
});

View File

@ -1,7 +1,7 @@
import { defineConfig } from "tsup"; import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
entry: ["src/index.ts", "src/commands/index.ts"], entry: ["src/index.ts", "src/commands/index.ts", "src/bt/index.ts"],
format: ["esm", "cjs"], format: ["esm", "cjs"],
dts: true, dts: true,
clean: true, clean: true,