feat(bt): Add wait and whilst, rename leaf to action

This commit is contained in:
hyper 2026-06-28 15:07:34 +08:00
parent 204b9100e6
commit 9bf0e5e049
8 changed files with 571 additions and 319 deletions

View File

@ -397,37 +397,44 @@ Behaviour trees control game flow by composing tasks into a tree. Each node in t
```ts ```ts
import { defineComponent, entity } from "ecs-observable"; import { defineComponent, entity } from "ecs-observable";
import { buildTree, Cancel, leaf, parallel, 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 ```ts
leaf(() => { doWork(); }) action(() => { doWork(); })
``` ```
**Fail** — throw any error. **Fail** — throw any error.
```ts ```ts
leaf(() => { throw new Error("bad"); }) action(() => { throw new Error("bad"); })
``` ```
**Cancel** — throw the `Cancel` symbol. **Cancel** — throw the `Cancel` symbol.
```ts ```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 ```ts
leaf(function* tickTimer() { wait((world, entity, task) => {
while (true) { startAnimation(() => task.succeed());
const dt: number = yield; // delta time from runner.tick(dt) })
```
**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; timer.accumulator += dt;
if (timer.accumulator >= timer.interval) { if (timer.accumulator >= timer.interval) {
// ... act ... // ... act ...
} }
} }),
}) )
``` ```
#### Composite nodes #### 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 parallel([a, b, c]) // all at once, all must succeed
random([a, b, c]) // pick one child each activation random([a, b, c]) // pick one child each activation
cycle(a) // scheduling boundary — re-run child on future ticks 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: Non-task entity children are materialized but ignored by the runner:
@ -447,8 +455,8 @@ const Label = defineComponent("label", { value: "" });
sequential([ sequential([
entity(Label, { value: "main sequence" }), entity(Label, { value: "main sequence" }),
leaf(handleInput), action(handleInput),
leaf(render), action(render),
]) ])
``` ```
@ -458,16 +466,16 @@ sequential([
const runner = buildTree( const runner = buildTree(
world, world,
parallel([ parallel([
leaf(function* physicsLoop() { whilst(
while (true) { () => true,
const dt: number = yield; action((_world, _entity, dt) => {
updatePhysics(dt); updatePhysics(dt);
} }),
}), ),
cycle( cycle(
sequential([ sequential([
leaf(() => { handleInput(); }), action(() => { handleInput(); }),
leaf(() => { render(); }), action(() => { render(); }),
]), ]),
), ),
]), ]),

View File

@ -3,11 +3,11 @@
// Architecture: // Architecture:
// Behaviour Tree (buildTree) — controls game flow: // Behaviour Tree (buildTree) — controls game flow:
// parallel // parallel
// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand // ├── dealerPlay (action) — generator loop, auto-plays dealer hand
// └── cycle // └── cycle
// └── seq (sequential) // └── seq (sequential)
// ├── handleInput (leaf) — reads queued commands // ├── handleInput (action) — reads queued commands
// └── render (leaf) — draws via blessed // └── render (action) — draws via blessed
// //
// CommandQueue — processes input: // CommandQueue — processes input:
// Keyboard → spawn command entities → CommandQueue.execute() // Keyboard → spawn command entities → CommandQueue.execute()
@ -25,10 +25,11 @@
import { World } from "../../src/index"; import { World } from "../../src/index";
import { import {
buildTree, buildTree,
leaf, action,
parallel, parallel,
cycle, cycle,
sequential, sequential,
whilst,
} from "../../src/bt/index"; } from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index"; import { CommandQueue } from "../../src/commands/index";
@ -72,11 +73,11 @@ registerCommands(world, commands, cards);
const runner = buildTree( const runner = buildTree(
world, world,
parallel([ parallel([
leaf(function* dealerPlay() { whilst(
while (true) { () => true,
yield; action(() => {
const phase = world.getSingleton(GamePhase); const phase = world.getSingleton(GamePhase);
if (phase.phase !== "dealerTurn") continue; if (phase.phase !== "dealerTurn") return;
if (dealerShouldHit(cards.getHand(InDealerHand))) { if (dealerShouldHit(cards.getHand(InDealerHand))) {
const cardEntity = cards.drawCard(); const cardEntity = cards.drawCard();
@ -86,14 +87,14 @@ const runner = buildTree(
} else { } else {
resolveRound(world, cards); resolveRound(world, cards);
} }
} }),
}), ),
cycle( cycle(
sequential([ sequential([
leaf(() => { action(() => {
commands.execute(); commands.execute();
}), }),
leaf(() => { action(() => {
render(world, ui); render(world, ui);
}), }),
]), ]),

View File

@ -3,11 +3,11 @@
// Architecture: // Architecture:
// Behaviour Tree (buildTree) — controls game flow: // Behaviour Tree (buildTree) — controls game flow:
// parallel // parallel
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer // ├── gravityTick (action) — generator loop, auto-drop piece on timer
// └── cycle // └── cycle
// └── seq (sequential) // └── seq (sequential)
// ├── handleInput (leaf) — reads queued commands // ├── handleInput (action) — reads queued commands
// └── render (leaf) — draws via blessed // └── render (action) — draws via blessed
// //
// CommandQueue — processes input: // CommandQueue — processes input:
// Keyboard → spawn command entities → CommandQueue.execute() // Keyboard → spawn command entities → CommandQueue.execute()
@ -22,10 +22,11 @@
import { World } from "../../src/index"; import { World } from "../../src/index";
import { import {
buildTree, buildTree,
leaf, action,
parallel, parallel,
cycle, cycle,
sequential, sequential,
whilst,
} from "../../src/bt/index"; } from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index"; import { CommandQueue } from "../../src/commands/index";
@ -73,11 +74,11 @@ pieces.spawnPiece();
const runner = buildTree( const runner = buildTree(
world, world,
parallel([ parallel([
leaf(function* gravityTick() { whilst(
while (true) { () => true,
const dt: number = yield; action((_world, _entity, dt) => {
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
continue; return;
} }
const timer = world.getSingleton(TickTimer); const timer = world.getSingleton(TickTimer);
timer.accumulator += dt; timer.accumulator += dt;
@ -93,14 +94,14 @@ const runner = buildTree(
} }
} }
} }
} }),
}), ),
cycle( cycle(
sequential([ sequential([
leaf(() => { action(() => {
commands.execute(); commands.execute();
}), }),
leaf(() => { action(() => {
render(world, ui); render(world, ui);
}), }),
]), ]),

View File

@ -11,16 +11,31 @@ export {
export type { TaskKind } from "./task"; export type { TaskKind } from "./task";
export { TaskRunner } from "./runner"; export { TaskRunner } from "./runner";
export type { LeafHandler, TerminalHandler } from "./runner"; export type {
ActionHandler,
WaitHandler,
ConditionHandler,
TaskControl,
TerminalHandler,
} from "./runner";
export { export {
buildTree, buildTree,
Cancel, Cancel,
leaf, action,
wait,
whilst,
sequential, sequential,
parallel, parallel,
selector, selector,
random, random,
cycle, cycle,
} from "./tree-def"; } from "./tree-def";
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def"; export type {
TreeDef,
TaskEntityDef,
TaskMeta,
ActionFn,
WaitFn,
ConditionFn,
} from "./tree-def";

View File

@ -9,11 +9,34 @@ import {
Cancelled, Cancelled,
TERMINAL_TAGS, TERMINAL_TAGS,
ChildOf, ChildOf,
Cancel,
} from "./task"; } from "./task";
// ── Types ───────────────────────────────────────────── // ── Types ─────────────────────────────────────────────
/** Callback invoked when a leaf task starts executing. */ /** Control object passed to wait tasks so they can complete themselves. */
export type LeafHandler = (world: World, entity: Entity, dt: number) => void; 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. */ /** Callback invoked when a task reaches a terminal status. */
export type TerminalHandler = ( export type TerminalHandler = (
@ -69,23 +92,11 @@ function parentOf(world: World, child: Entity): Entity | null {
* Push-based behaviour-tree runner. * Push-based behaviour-tree runner.
* *
* Tasks tagged with `Scheduled` define tick boundaries. Once a scheduled * Tasks tagged with `Scheduled` define tick boundaries. Once a scheduled
* task starts, child/parent propagation is immediate until a running leaf or * task starts, child/parent propagation is immediate until a running wait task
* an explicit scheduling boundary (such as `cycle`) yields to a future tick. * or an explicit scheduling boundary (`cycle`, `whilst`) yields to a future tick.
* *
* Leaves are dispatched to a user-provided `onLeaf` callback. * Action, wait, and whilst condition callbacks are supplied by `buildTree` or
* Terminal results are dispatched to a user-provided `onTerminal` callback. * assigned directly when using `TaskRunner` manually.
*
* @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 { export class TaskRunner {
private _world: World; private _world: World;
@ -95,8 +106,14 @@ export class TaskRunner {
/** Root task entity, set by `buildTree` for convenience. */ /** Root task entity, set by `buildTree` for convenience. */
root?: Entity; root?: Entity;
/** Called when a leaf task starts executing. */ /** Called when an action task starts executing. */
onLeaf: LeafHandler = () => {}; 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. */ /** Called when any task reaches a terminal status. */
onTerminal: TerminalHandler = () => {}; 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 { succeed(entity: Entity): void {
this._finish(entity, Succeeded); 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 { fail(entity: Entity): void {
this._finish(entity, Failed); this._finish(entity, Failed);
} }
@ -170,8 +187,11 @@ export class TaskRunner {
try { try {
switch (t.kind) { switch (t.kind) {
case "leaf": case "action":
this._executeLeaf(entity, dt); this._executeAction(entity, dt);
break;
case "wait":
this._executeWait(entity, dt);
break; break;
case "sequential": case "sequential":
this._executeSequential(entity, dt); this._executeSequential(entity, dt);
@ -185,6 +205,9 @@ export class TaskRunner {
case "cycle": case "cycle":
this._executeCycle(entity, dt); this._executeCycle(entity, dt);
break; break;
case "whilst":
this._executeWhilst(entity, dt);
break;
case "selector": case "selector":
this._executeSelector(entity, dt); this._executeSelector(entity, dt);
break; break;
@ -210,9 +233,31 @@ export class TaskRunner {
this._execute(entity, dt); this._execute(entity, dt);
} }
private _executeLeaf(entity: Entity, dt: number): void { private _executeAction(entity: Entity, dt: number): void {
this._world.add(entity, Running); 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 { private _executeSequential(entity: Entity, dt: number): void {
@ -307,28 +352,51 @@ export class TaskRunner {
} }
private _executeCycle(entity: Entity, dt: number): void { private _executeCycle(entity: Entity, dt: number): void {
const children = [...childrenOf(this._world, entity)]; const child = this._firstTaskChild(entity);
if (!child) return;
// 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;
}
this._executeChild(child, dt); this._executeChild(child, dt);
if (isTerminal(this._world, child)) { if (isTerminal(this._world, child)) {
clearSubtree(this._world, child); clearSubtree(this._world, child);
this._world.add(child, Scheduled); this._world.add(entity, Scheduled);
} }
// Cycle itself never terminates — it just creates tick-boundary cycles. // 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 { private _executeSelector(entity: Entity, dt: number): void {
for (const child of childrenOf(this._world, entity)) { for (const child of childrenOf(this._world, entity)) {
let status = terminalStatus(this._world, child); let status = terminalStatus(this._world, child);
@ -353,8 +421,23 @@ export class TaskRunner {
this._finish(entity, Failed); this._finish(entity, Failed);
} }
private _firstTaskChild(entity: Entity): Entity | null {
for (const child of childrenOf(this._world, entity)) {
return child;
}
return null;
}
// ── Completion propagation ──────────────────────── // ── Completion propagation ────────────────────────
private _finishFromThrown(entity: Entity, err: unknown): void {
if (err === Cancel) {
this.cancel(entity);
} else {
this.fail(entity);
}
}
private _finish( private _finish(
entity: Entity, entity: Entity,
tag: typeof Succeeded | typeof Failed | typeof Cancelled, tag: typeof Succeeded | typeof Failed | typeof Cancelled,

View File

@ -1,12 +1,18 @@
import { defineComponent } from "../component"; import { defineComponent } from "../component";
import { defineRelationship } from "../relationship"; 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 ──────────────────────────────────── // ── Task component ────────────────────────────────────
/** /**
* Core component for behaviour-tree tasks. * Core component for behaviour-tree tasks.
* *
* `kind` determines how the task evaluates its children: * `kind` determines how the task evaluates:
* - `"leaf"` terminal node; external logic drives it to completion. * - `"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. * - `"sequential"` runs children one at a time, left to right.
* Succeeds when all children succeed; fails when any child fails. * Succeeds when all children succeed; fails when any child fails.
* - `"parallel"` starts all children at once. * - `"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 * - `"cycle"` runs its single child. When the child finishes, resets it
* and schedules the next run for a future tick boundary. Never terminates * and schedules the next run for a future tick boundary. Never terminates
* on its own (only via cancel). * 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 * - `"selector"` runs children left to right. Succeeds on the first
* child that succeeds; fails only if all children fail. * child that succeeds; fails only if all children fail.
*/ */
export const Task = defineComponent("task", { export const Task = defineComponent("task", {
kind: "leaf" as kind: "action" as
"leaf" | "sequential" | "parallel" | "random" | "cycle" | "selector", | "action"
| "wait"
| "sequential"
| "parallel"
| "random"
| "cycle"
| "whilst"
| "selector",
}); });
export type TaskKind = (typeof Task.type)["kind"]; 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. */ /** A task that should be executed this tick. */
export const Scheduled = defineComponent("scheduled", {}); 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", {}); export const Running = defineComponent("running", {});
/** The task completed successfully. */ /** The task completed successfully. */

View File

@ -1,35 +1,32 @@
import type { World, Entity } from "../index"; import type { World, Entity } from "../index";
import type { EntityDef, EntityDefChild } from "../entity-tree"; import type { EntityDef, EntityDefChild } from "../entity-tree";
import { Task, ChildOf } from "./task"; import { Task, ChildOf, Cancel } from "./task";
import { TaskRunner } from "./runner"; import { TaskRunner, type TaskControl } from "./runner";
// ── Cancel ──────────────────────────────────────────── export { Cancel };
/** // ── Task callback types ───────────────────────────────
* 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");
// ── 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. */ /** Starts a task that remains Running until completed by the supplied control. */
export type LeafFn = export type WaitFn = (
| ((world: World, dt: number) => void) world: World,
| (() => Generator<number | void, void, number>); entity: Entity,
control: TaskControl,
dt: number,
) => void;
export interface LeafTaskMeta { /** Controls a `whilst` task. False means the loop has completed successfully. */
readonly run: LeafFn; export type ConditionFn = (world: World, entity: Entity, dt: number) => boolean;
}
export type TaskEntityDef = EntityDef< export type TaskMeta =
typeof Task.type, | { readonly mode: "action"; readonly run: ActionFn }
LeafTaskMeta | undefined | { 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`. */ /** Behaviour-tree definition accepted by `buildTree`. */
export type TreeDef = TaskEntityDef; export type TreeDef = TaskEntityDef;
@ -43,7 +40,7 @@ function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
function task( function task(
kind: typeof Task.type.kind, kind: typeof Task.type.kind,
children: readonly EntityDefChild[] = [], children: readonly EntityDefChild[] = [],
meta?: LeafTaskMeta, meta?: TaskMeta,
): TaskEntityDef { ): TaskEntityDef {
return { return {
kind: "entity", kind: "entity",
@ -54,12 +51,38 @@ function task(
}; };
} }
/** Create a leaf task entity definition. */ /** Create an action task entity definition. */
export function leaf( export function action(
run: LeafFn, run: ActionFn,
children: readonly EntityDefChild[] = [], children: readonly EntityDefChild[] = [],
): TaskEntityDef { ): 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. */ /** 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 ─────────────────────────────────────────── // ── Builder ───────────────────────────────────────────
/** /**
* Materialize a behaviour-tree definition into ECS entities and return a * Materialize a behaviour-tree definition into ECS entities and return a
* fully-wired `TaskRunner`. * fully-wired `TaskRunner`.
* *
* Definitions are `EntityDef` trees produced by the task factories (`leaf`, * Definitions are `EntityDef` trees produced by the task factories (`action`,
* `sequential`, `parallel`, `selector`, `random`, `cycle`) and generic * `wait`, `sequential`, `parallel`, `selector`, `random`, `cycle`, `whilst`)
* single-component entity factories. Non-task child entities are materialized * and generic single-component entity factories. Non-task child entities are
* into the ECS tree but ignored by `TaskRunner` execution. * 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.
*/ */
export function buildTree(world: World, def: TreeDef): TaskRunner { export function buildTree(world: World, def: TreeDef): TaskRunner {
const leafHandlers = new Map<Entity, LeafFn>(); const actions = new Map<Entity, ActionFn>();
// Track generator iterators for multi-frame leaves const waits = new Map<Entity, WaitFn>();
const generators = new Map<Entity, Generator<number | void, void, number>>(); const conditions = new Map<Entity, ConditionFn>();
function build(def: EntityDef, parent?: Entity): Entity { function build(def: EntityDef, parent?: Entity): Entity {
const entity = world.spawn(); const entity = world.spawn();
@ -140,12 +179,22 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
if (def.component === Task) { if (def.component === Task) {
const taskData = world.get(entity, Task); const taskData = world.get(entity, Task);
if (taskData.kind === "leaf") { const meta = def.meta as TaskMeta | undefined;
const run = (def.meta as LeafTaskMeta | undefined)?.run;
if (!run) { if (taskData.kind === "action") {
throw new Error("Leaf task entity is missing a run function"); 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); const runner = new TaskRunner(world);
runner.root = root; runner.root = root;
runner.onLeaf = (_w, entity, dt) => { runner.onAction = (world, entity, dt) => {
const handler = leafHandlers.get(entity); actions.get(entity)?.(world, entity, dt);
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.onTerminal = (_w, entity) => { runner.onWait = (world, entity, control, dt) => {
// Clean up generator when a leaf reaches terminal by external means waits.get(entity)?.(world, entity, control, dt);
generators.delete(entity); };
runner.onCondition = (world, entity, dt) => {
const condition = conditions.get(entity);
return condition ? condition(world, entity, dt) : false;
}; };
return runner; return runner;

View File

@ -10,15 +10,18 @@ import {
ChildOf, ChildOf,
TaskRunner, TaskRunner,
buildTree, buildTree,
leaf, Cancel,
action,
wait,
cycle, cycle,
whilst,
sequential, sequential,
} from "../src/bt/index"; } from "../src/bt/index";
// ── Helpers ───────────────────────────────────────── // ── Helpers ─────────────────────────────────────────
function makeLeaf(world: World, parent?: Entity): Entity { function makeWait(world: World, parent?: Entity): Entity {
const e = world.spawn(); const e = world.spawn();
world.add(e, Task, { kind: "leaf" }); world.add(e, Task, { kind: "wait" });
if (parent) world.relate(e, ChildOf, parent); if (parent) world.relate(e, ChildOf, parent);
return e; return e;
} }
@ -69,9 +72,9 @@ describe("Entity task factories", () => {
world, world,
sequential([ sequential([
entity(Label, { value: "sequence metadata" }), entity(Label, { value: "sequence metadata" }),
leaf(() => calls.push("a")), action(() => calls.push("a")),
entity(Label, { value: "between leaves" }), 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); 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", () => { it("cycle works with component child entities beside its task child", () => {
const Label = defineComponent("cycleLabel", { value: "" }); const Label = defineComponent("cycleLabel", { value: "" });
const world = new World(); const world = new World();
@ -99,7 +218,7 @@ describe("Entity task factories", () => {
world, world,
cycle([ cycle([
entity(Label, { value: "cycle metadata" }), entity(Label, { value: "cycle metadata" }),
leaf(() => { action(() => {
calls++; calls++;
}), }),
]), ]),
@ -112,8 +231,8 @@ describe("Entity task factories", () => {
}); });
}); });
// ── Leaf tasks ────────────────────────────────────── // ── Wait tasks ──────────────────────────────────────
describe("Leaf tasks", () => { describe("Wait tasks", () => {
let world: World; let world: World;
let runner: TaskRunner; let runner: TaskRunner;
@ -122,79 +241,79 @@ describe("Leaf tasks", () => {
runner = new TaskRunner(world); runner = new TaskRunner(world);
}); });
it("calls onLeaf when a leaf is scheduled and ticked", () => { it("calls onWait when a wait task is scheduled and ticked", () => {
const leaf = makeLeaf(world); const action = makeWait(world);
const calls: Entity[] = []; const calls: Entity[] = [];
runner.onLeaf = (_w, e) => calls.push(e); runner.onWait = (_w, e) => calls.push(e);
runner.schedule(leaf); runner.schedule(action);
runner.tick(); runner.tick();
expect(calls).toEqual([leaf]); expect(calls).toEqual([action]);
}); });
it("marks leaf as Running after tick", () => { it("marks wait task as Running after tick", () => {
const leaf = makeLeaf(world); const action = makeWait(world);
runner.schedule(leaf); runner.schedule(action);
runner.tick(); runner.tick();
expect(world.has(leaf, Running)).toBe(true); expect(world.has(action, Running)).toBe(true);
expect(world.has(leaf, Scheduled)).toBe(false); expect(world.has(action, Scheduled)).toBe(false);
}); });
it("succeed() marks leaf as Succeeded", () => { it("succeed() marks wait task as Succeeded", () => {
const leaf = makeLeaf(world); const action = makeWait(world);
runner.schedule(leaf); runner.schedule(action);
runner.tick(); runner.tick();
runner.succeed(leaf); runner.succeed(action);
expect(world.has(leaf, Succeeded)).toBe(true); expect(world.has(action, Succeeded)).toBe(true);
expect(world.has(leaf, Running)).toBe(false); expect(world.has(action, Running)).toBe(false);
}); });
it("fail() marks leaf as Failed", () => { it("fail() marks wait task as Failed", () => {
const leaf = makeLeaf(world); const action = makeWait(world);
runner.schedule(leaf); runner.schedule(action);
runner.tick(); runner.tick();
runner.fail(leaf); runner.fail(action);
expect(world.has(leaf, Failed)).toBe(true); expect(world.has(action, Failed)).toBe(true);
expect(world.has(leaf, Running)).toBe(false); expect(world.has(action, Running)).toBe(false);
}); });
it("cancel() marks leaf as Cancelled", () => { it("cancel() marks wait task as Cancelled", () => {
const leaf = makeLeaf(world); const action = makeWait(world);
runner.schedule(leaf); runner.schedule(action);
runner.tick(); runner.tick();
runner.cancel(leaf); runner.cancel(action);
expect(world.has(leaf, Cancelled)).toBe(true); expect(world.has(action, Cancelled)).toBe(true);
expect(world.has(leaf, Running)).toBe(false); expect(world.has(action, Running)).toBe(false);
}); });
it("onTerminal is called when leaf finishes", () => { it("onTerminal is called when wait task finishes", () => {
const leaf = makeLeaf(world); const action = makeWait(world);
const terminals: { entity: Entity; status: string }[] = []; const terminals: { entity: Entity; status: string }[] = [];
runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s }); runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s });
runner.schedule(leaf); runner.schedule(action);
runner.tick(); 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", () => { it("reset() clears all status tags", () => {
const leaf = makeLeaf(world); const action = makeWait(world);
runner.schedule(leaf); runner.schedule(action);
runner.tick(); runner.tick();
runner.succeed(leaf); runner.succeed(action);
runner.reset(leaf); runner.reset(action);
expect(world.has(leaf, Succeeded)).toBe(false); expect(world.has(action, Succeeded)).toBe(false);
expect(world.has(leaf, Running)).toBe(false); expect(world.has(action, Running)).toBe(false);
expect(world.has(leaf, Scheduled)).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", () => { it("runs children one at a time in order", () => {
const seq = makeSequential(world); const seq = makeSequential(world);
const a = makeLeaf(world, seq); const a = makeWait(world, seq);
const b = makeLeaf(world, seq); const b = makeWait(world, seq);
const c = makeLeaf(world, seq); const c = makeWait(world, seq);
const leafCalls: Entity[] = []; const leafCalls: Entity[] = [];
runner.onLeaf = (_w, e) => leafCalls.push(e); runner.onWait = (_w, e) => leafCalls.push(e);
runner.schedule(seq); runner.schedule(seq);
runner.tick(); runner.tick();
@ -236,8 +355,8 @@ describe("Sequential tasks", () => {
it("fails immediately when a child fails", () => { it("fails immediately when a child fails", () => {
const seq = makeSequential(world); const seq = makeSequential(world);
const a = makeLeaf(world, seq); const a = makeWait(world, seq);
const b = makeLeaf(world, seq); const b = makeWait(world, seq);
runner.schedule(seq); runner.schedule(seq);
runner.tick(); // schedules a runner.tick(); // schedules a
@ -254,8 +373,8 @@ describe("Sequential tasks", () => {
it("succeeds when all children succeed", () => { it("succeeds when all children succeed", () => {
const seq = makeSequential(world); const seq = makeSequential(world);
const a = makeLeaf(world, seq); const a = makeWait(world, seq);
const b = makeLeaf(world, seq); const b = makeWait(world, seq);
runner.schedule(seq); runner.schedule(seq);
runner.tick(); // schedules a runner.tick(); // schedules a
@ -272,20 +391,20 @@ describe("Sequential tasks", () => {
it("propagates terminal to grandparent", () => { it("propagates terminal to grandparent", () => {
const root = makeSequential(world); const root = makeSequential(world);
const child = makeSequential(world, root); const child = makeSequential(world, root);
const leaf = makeLeaf(world, child); const action = makeWait(world, child);
const terminals: Entity[] = []; const terminals: Entity[] = [];
runner.onTerminal = (_w, e) => terminals.push(e); runner.onTerminal = (_w, e) => terminals.push(e);
runner.schedule(root); runner.schedule(root);
runner.tick(); // schedules child runner.tick(); // schedules child
runner.tick(); // schedules leaf runner.tick(); // schedules action
runner.tick(); // runs leaf runner.tick(); // runs action
runner.succeed(leaf); runner.succeed(action);
runner.tick(); // child succeeds runner.tick(); // child succeeds
runner.tick(); // root succeeds runner.tick(); // root succeeds
expect(terminals).toEqual([leaf, child, root]); expect(terminals).toEqual([action, child, root]);
expect(world.has(root, Succeeded)).toBe(true); expect(world.has(root, Succeeded)).toBe(true);
}); });
@ -311,9 +430,9 @@ describe("Parallel tasks", () => {
it("starts all children at once", () => { it("starts all children at once", () => {
const par = makeParallel(world); const par = makeParallel(world);
const a = makeLeaf(world, par); const a = makeWait(world, par);
const b = makeLeaf(world, par); const b = makeWait(world, par);
const c = makeLeaf(world, par); const c = makeWait(world, par);
runner.schedule(par); runner.schedule(par);
runner.tick(); runner.tick();
@ -325,8 +444,8 @@ describe("Parallel tasks", () => {
it("succeeds when all children succeed", () => { it("succeeds when all children succeed", () => {
const par = makeParallel(world); const par = makeParallel(world);
const a = makeLeaf(world, par); const a = makeWait(world, par);
const b = makeLeaf(world, par); const b = makeWait(world, par);
runner.schedule(par); runner.schedule(par);
runner.tick(); // schedules both runner.tick(); // schedules both
@ -355,8 +474,8 @@ describe("Parallel tasks", () => {
it("fails immediately when any child fails", () => { it("fails immediately when any child fails", () => {
const par = makeParallel(world); const par = makeParallel(world);
const a = makeLeaf(world, par); const a = makeWait(world, par);
const b = makeLeaf(world, par); const b = makeWait(world, par);
runner.schedule(par); runner.schedule(par);
runner.tick(); // schedules both runner.tick(); // schedules both
@ -391,8 +510,8 @@ describe("Random tasks", () => {
it("picks one child and succeeds/fails with it", () => { it("picks one child and succeeds/fails with it", () => {
const rand = makeRandom(world); const rand = makeRandom(world);
const a = makeLeaf(world, rand); const a = makeWait(world, rand);
const b = makeLeaf(world, rand); const b = makeWait(world, rand);
runner.schedule(rand); runner.schedule(rand);
runner.tick(); runner.tick();
@ -409,7 +528,7 @@ describe("Random tasks", () => {
it("fails when picked child fails", () => { it("fails when picked child fails", () => {
const rand = makeRandom(world); const rand = makeRandom(world);
const a = makeLeaf(world, rand); const a = makeWait(world, rand);
runner.schedule(rand); runner.schedule(rand);
runner.tick(); // schedules a (only child) runner.tick(); // schedules a (only child)
@ -444,55 +563,55 @@ describe("Cycle tasks", () => {
it("re-runs child after it succeeds", () => { it("re-runs child after it succeeds", () => {
const cyc = makeCycle(world); const cyc = makeCycle(world);
const leaf = makeLeaf(world, cyc); const action = makeWait(world, cyc);
let leafCount = 0; let waitCount = 0;
runner.onLeaf = () => leafCount++; runner.onWait = () => waitCount++;
runner.schedule(cyc); runner.schedule(cyc);
runner.tick(); runner.tick();
expect(leafCount).toBe(1); expect(waitCount).toBe(1);
runner.succeed(leaf); runner.succeed(action);
// Completion schedules the next cycle at a tick boundary. // Completion schedules the cycle node at a tick boundary.
expect(world.has(leaf, Scheduled)).toBe(true); expect(world.has(cyc, Scheduled)).toBe(true);
runner.tick(); runner.tick();
expect(leafCount).toBe(2); expect(waitCount).toBe(2);
runner.succeed(leaf); runner.succeed(action);
runner.tick(); runner.tick();
expect(leafCount).toBe(3); expect(waitCount).toBe(3);
}); });
it("re-runs child after it fails", () => { it("re-runs child after it fails", () => {
const cyc = makeCycle(world); const cyc = makeCycle(world);
const leaf = makeLeaf(world, cyc); const action = makeWait(world, cyc);
let leafCount = 0; let waitCount = 0;
runner.onLeaf = () => leafCount++; runner.onWait = () => waitCount++;
runner.schedule(cyc); runner.schedule(cyc);
runner.tick(); runner.tick();
runner.fail(leaf); runner.fail(action);
expect(world.has(leaf, Scheduled)).toBe(true); expect(world.has(cyc, Scheduled)).toBe(true);
runner.tick(); runner.tick();
expect(leafCount).toBe(2); expect(waitCount).toBe(2);
}); });
it("never terminates on its own", () => { it("never terminates on its own", () => {
const cyc = makeCycle(world); const cyc = makeCycle(world);
const leaf = makeLeaf(world, cyc); const action = makeWait(world, cyc);
runner.schedule(cyc); runner.schedule(cyc);
runner.tick(); runner.tick();
runner.succeed(leaf); runner.succeed(action);
// After many cycles, cycle is still not terminal // After many cycles, cycle is still not terminal
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
runner.tick(); runner.tick();
runner.succeed(leaf); runner.succeed(action);
} }
expect(world.has(cyc, Succeeded)).toBe(false); expect(world.has(cyc, Succeeded)).toBe(false);
@ -502,7 +621,7 @@ describe("Cycle tasks", () => {
it("can be cancelled", () => { it("can be cancelled", () => {
const cyc = makeCycle(world); const cyc = makeCycle(world);
const leaf = makeLeaf(world, cyc); const action = makeWait(world, cyc);
runner.schedule(cyc); runner.schedule(cyc);
runner.tick(); runner.tick();
@ -510,7 +629,7 @@ describe("Cycle tasks", () => {
runner.cancel(cyc); runner.cancel(cyc);
expect(world.has(cyc, Cancelled)).toBe(true); 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", () => { it("empty cycle does nothing", () => {
@ -527,8 +646,8 @@ describe("Cycle tasks", () => {
it("cycle inside sequential advances parent when cancelled", () => { it("cycle inside sequential advances parent when cancelled", () => {
const seq = makeSequential(world); const seq = makeSequential(world);
const cyc = makeCycle(world, seq); const cyc = makeCycle(world, seq);
const leaf = makeLeaf(world, cyc); const action = makeWait(world, cyc);
const after = makeLeaf(world, seq); const after = makeWait(world, seq);
runner.schedule(seq); runner.schedule(seq);
runner.tick(); runner.tick();
@ -553,8 +672,8 @@ describe("Selector tasks", () => {
it("succeeds on first child that succeeds", () => { it("succeeds on first child that succeeds", () => {
const sel = makeSelector(world); const sel = makeSelector(world);
const a = makeLeaf(world, sel); const a = makeWait(world, sel);
const b = makeLeaf(world, sel); const b = makeWait(world, sel);
runner.schedule(sel); runner.schedule(sel);
runner.tick(); // schedules a runner.tick(); // schedules a
@ -571,9 +690,9 @@ describe("Selector tasks", () => {
it("tries next child when previous fails", () => { it("tries next child when previous fails", () => {
const sel = makeSelector(world); const sel = makeSelector(world);
const a = makeLeaf(world, sel); const a = makeWait(world, sel);
const b = makeLeaf(world, sel); const b = makeWait(world, sel);
const c = makeLeaf(world, sel); const c = makeWait(world, sel);
runner.schedule(sel); runner.schedule(sel);
runner.tick(); // schedules a runner.tick(); // schedules a
@ -591,8 +710,8 @@ describe("Selector tasks", () => {
it("fails when all children fail", () => { it("fails when all children fail", () => {
const sel = makeSelector(world); const sel = makeSelector(world);
const a = makeLeaf(world, sel); const a = makeWait(world, sel);
const b = makeLeaf(world, sel); const b = makeWait(world, sel);
runner.schedule(sel); runner.schedule(sel);
runner.tick(); // schedules a runner.tick(); // schedules a
@ -617,8 +736,8 @@ describe("Selector tasks", () => {
it("skips cancelled children and continues", () => { it("skips cancelled children and continues", () => {
const sel = makeSelector(world); const sel = makeSelector(world);
const a = makeLeaf(world, sel); const a = makeWait(world, sel);
const b = makeLeaf(world, sel); const b = makeWait(world, sel);
runner.schedule(sel); runner.schedule(sel);
runner.tick(); // schedules a runner.tick(); // schedules a
@ -642,8 +761,8 @@ describe("Cancel", () => {
it("cancels all descendants", () => { it("cancels all descendants", () => {
const root = makeSequential(world); const root = makeSequential(world);
const child = makeParallel(world, root); const child = makeParallel(world, root);
const a = makeLeaf(world, child); const a = makeWait(world, child);
const b = makeLeaf(world, child); const b = makeWait(world, child);
runner.schedule(root); runner.schedule(root);
runner.tick(); // schedules child runner.tick(); // schedules child
@ -662,17 +781,17 @@ describe("Cancel", () => {
it("cancel propagates to parent", () => { it("cancel propagates to parent", () => {
const root = makeSequential(world); const root = makeSequential(world);
const child = makeSequential(world, root); const child = makeSequential(world, root);
const leaf = makeLeaf(world, child); const action = makeWait(world, child);
runner.schedule(root); runner.schedule(root);
runner.tick(); // schedules child runner.tick(); // schedules child
runner.tick(); // schedules leaf runner.tick(); // schedules action
runner.tick(); // runs leaf runner.tick(); // runs action
runner.cancel(leaf); runner.cancel(action);
// leaf cancelled → child re-scheduled → child sees leaf cancelled → child cancelled // action cancelled → child re-scheduled → child sees action cancelled → child cancelled
runner.tick(); // child processes cancelled leaf runner.tick(); // child processes cancelled action
expect(world.has(child, Cancelled)).toBe(true); expect(world.has(child, Cancelled)).toBe(true);
// child cancelled → root re-scheduled → root sees child cancelled → root cancelled // child cancelled → root re-scheduled → root sees child cancelled → root cancelled
@ -681,8 +800,8 @@ describe("Cancel", () => {
}); });
}); });
// ── Multi-frame leaves ────────────────────────────── // ── Multi-frame wait tasks ──────────────────────────────
describe("Multi-frame leaves", () => { describe("Multi-frame wait tasks", () => {
let world: World; let world: World;
let runner: TaskRunner; let runner: TaskRunner;
@ -691,28 +810,28 @@ describe("Multi-frame leaves", () => {
runner = new TaskRunner(world); runner = new TaskRunner(world);
}); });
it("leaf stays Running across ticks until explicitly finished", () => { it("wait task stays Running across ticks until explicitly finished", () => {
const leaf = makeLeaf(world); const action = makeWait(world);
runner.schedule(leaf); runner.schedule(action);
runner.tick(); 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(); runner.tick();
expect(world.has(leaf, Running)).toBe(true); expect(world.has(action, Running)).toBe(true);
// External system finishes it // External system finishes it
runner.succeed(leaf); runner.succeed(action);
expect(world.has(leaf, Succeeded)).toBe(true); expect(world.has(action, Succeeded)).toBe(true);
expect(world.has(leaf, Running)).toBe(false); 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 seq = makeSequential(world);
const a = makeLeaf(world, seq); const a = makeWait(world, seq);
const b = makeLeaf(world, seq); const b = makeWait(world, seq);
runner.schedule(seq); runner.schedule(seq);
runner.tick(); // schedules a runner.tick(); // schedules a
@ -754,11 +873,11 @@ describe("Edge cases", () => {
it("tick is a no-op when nothing is scheduled", () => { it("tick is a no-op when nothing is scheduled", () => {
const world = new World(); const world = new World();
const runner = new TaskRunner(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(() => runner.tick()).not.toThrow();
expect(world.has(leaf, Running)).toBe(false); expect(world.has(action, Running)).toBe(false);
}); });
it("deeply nested tree works correctly", () => { it("deeply nested tree works correctly", () => {
@ -767,14 +886,14 @@ describe("Edge cases", () => {
const root = makeSequential(world); const root = makeSequential(world);
const mid = makeSequential(world, root); const mid = makeSequential(world, root);
const leaf = makeLeaf(world, mid); const action = makeWait(world, mid);
runner.schedule(root); runner.schedule(root);
runner.tick(); 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(mid, Succeeded)).toBe(true);
expect(world.has(root, Succeeded)).toBe(true); expect(world.has(root, Succeeded)).toBe(true);
}); });