From 9bf0e5e04981fbc644304657d07c2b45e11cf067 Mon Sep 17 00:00:00 2001 From: hyper Date: Sun, 28 Jun 2026 15:07:34 +0800 Subject: [PATCH] feat(bt): Add wait and whilst, rename leaf to action --- USAGE.md | 50 +++-- examples/blackjack/main.ts | 25 +-- examples/tetris/main.ts | 25 +-- src/bt/index.ts | 21 +- src/bt/runner.ts | 161 ++++++++++++---- src/bt/task.ts | 25 ++- src/bt/tree-def.ts | 202 ++++++++++---------- test/bt.test.ts | 381 ++++++++++++++++++++++++------------- 8 files changed, 571 insertions(+), 319 deletions(-) diff --git a/USAGE.md b/USAGE.md index 1092b55..c61ea3d 100644 --- a/USAGE.md +++ b/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(); }), ]), ), ]), diff --git a/examples/blackjack/main.ts b/examples/blackjack/main.ts index cc2104b..924402a 100644 --- a/examples/blackjack/main.ts +++ b/examples/blackjack/main.ts @@ -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); }), ]), diff --git a/examples/tetris/main.ts b/examples/tetris/main.ts index 780f8df..68b5f47 100644 --- a/examples/tetris/main.ts +++ b/examples/tetris/main.ts @@ -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); }), ]), diff --git a/src/bt/index.ts b/src/bt/index.ts index 57002cb..2603bb5 100644 --- a/src/bt/index.ts +++ b/src/bt/index.ts @@ -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"; diff --git a/src/bt/runner.ts b/src/bt/runner.ts index 1af1ddd..57b9de2 100644 --- a/src/bt/runner.ts +++ b/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, diff --git a/src/bt/task.ts b/src/bt/task.ts index d002f4d..ee62aa9 100644 --- a/src/bt/task.ts +++ b/src/bt/task.ts @@ -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. */ diff --git a/src/bt/tree-def.ts b/src/bt/tree-def.ts index 86f74a9..6d67664 100644 --- a/src/bt/tree-def.ts +++ b/src/bt/tree-def.ts @@ -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); +/** 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; /** 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(); - // Track generator iterators for multi-frame leaves - const generators = new Map>(); + const actions = new Map(); + const waits = new Map(); + const conditions = new Map(); 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; - 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; diff --git a/test/bt.test.ts b/test/bt.test.ts index b0153a5..099b215 100644 --- a/test/bt.test.ts +++ b/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); });