feat(bt): Rename repeat to cycle and refactor runner

Rework task execution to be synchronous within a single tick.
Scheduling boundaries are now defined by `cycle` nodes, which
replace the previous `repeat` decorator.  All tasks are executed
immediately when scheduled, and parent/child propagation is
handled inline.
This commit is contained in:
hyper 2026-06-28 14:51:52 +08:00
parent 1253bf82d2
commit 204b9100e6
8 changed files with 254 additions and 265 deletions

View File

@ -397,7 +397,7 @@ Behaviour trees control game flow by composing tasks into a tree. Each node in t
```ts ```ts
import { defineComponent, entity } from "ecs-observable"; import { defineComponent, entity } from "ecs-observable";
import { buildTree, Cancel, leaf, parallel, repeat, sequential } from "ecs-observable/bt"; import { buildTree, Cancel, leaf, parallel, cycle, sequential } from "ecs-observable/bt";
``` ```
#### Leaf patterns #### Leaf patterns
@ -437,7 +437,7 @@ sequential([a, b, c]) // left-to-right, all must succeed
selector([a, b, c]) // left-to-right, first success wins selector([a, b, c]) // left-to-right, first success wins
parallel([a, b, c]) // all at once, all must succeed parallel([a, b, c]) // all at once, all must succeed
random([a, b, c]) // pick one child each activation random([a, b, c]) // pick one child each activation
repeat(a) // decorator — re-run child forever cycle(a) // scheduling boundary — re-run child on future ticks
``` ```
Non-task entity children are materialized but ignored by the runner: Non-task entity children are materialized but ignored by the runner:
@ -464,7 +464,7 @@ const runner = buildTree(
updatePhysics(dt); updatePhysics(dt);
} }
}), }),
repeat( cycle(
sequential([ sequential([
leaf(() => { handleInput(); }), leaf(() => { handleInput(); }),
leaf(() => { render(); }), leaf(() => { render(); }),

View File

@ -4,7 +4,7 @@
// Behaviour Tree (buildTree) — controls game flow: // Behaviour Tree (buildTree) — controls game flow:
// parallel // parallel
// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand // ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand
// └── repeat // └── cycle
// └── seq (sequential) // └── seq (sequential)
// ├── handleInput (leaf) — reads queued commands // ├── handleInput (leaf) — reads queued commands
// └── render (leaf) — draws via blessed // └── render (leaf) — draws via blessed
@ -27,7 +27,7 @@ import {
buildTree, buildTree,
leaf, leaf,
parallel, parallel,
repeat, cycle,
sequential, sequential,
} from "../../src/bt/index"; } from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index"; import { CommandQueue } from "../../src/commands/index";
@ -88,7 +88,7 @@ const runner = buildTree(
} }
} }
}), }),
repeat( cycle(
sequential([ sequential([
leaf(() => { leaf(() => {
commands.execute(); commands.execute();

View File

@ -4,7 +4,7 @@
// Behaviour Tree (buildTree) — controls game flow: // Behaviour Tree (buildTree) — controls game flow:
// parallel // parallel
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer // ├── gravityTick (leaf) — generator loop, auto-drop piece on timer
// └── repeat // └── cycle
// └── seq (sequential) // └── seq (sequential)
// ├── handleInput (leaf) — reads queued commands // ├── handleInput (leaf) — reads queued commands
// └── render (leaf) — draws via blessed // └── render (leaf) — draws via blessed
@ -24,7 +24,7 @@ import {
buildTree, buildTree,
leaf, leaf,
parallel, parallel,
repeat, cycle,
sequential, sequential,
} from "../../src/bt/index"; } from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index"; import { CommandQueue } from "../../src/commands/index";
@ -95,7 +95,7 @@ const runner = buildTree(
} }
} }
}), }),
repeat( cycle(
sequential([ sequential([
leaf(() => { leaf(() => {
commands.execute(); commands.execute();

View File

@ -21,6 +21,6 @@ export {
parallel, parallel,
selector, selector,
random, random,
repeat, cycle,
} from "./tree-def"; } from "./tree-def";
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def"; export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";

View File

@ -12,7 +12,7 @@ import {
} from "./task"; } from "./task";
// ── Types ───────────────────────────────────────────── // ── Types ─────────────────────────────────────────────
/** Callback invoked for each leaf task that becomes Scheduled. */ /** Callback invoked when a leaf task starts executing. */
export type LeafHandler = (world: World, entity: Entity, dt: number) => void; export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
/** Callback invoked when a task reaches a terminal status. */ /** Callback invoked when a task reaches a terminal status. */
@ -68,10 +68,9 @@ function parentOf(world: World, child: Entity): Entity | null {
/** /**
* Push-based behaviour-tree runner. * Push-based behaviour-tree runner.
* *
* Only tasks tagged with `Scheduled` are processed each tick. * Tasks tagged with `Scheduled` define tick boundaries. Once a scheduled
* When a task finishes, it notifies its parent, which may schedule * task starts, child/parent propagation is immediate until a running leaf or
* the next child (sequential), aggregate results (parallel), or * an explicit scheduling boundary (such as `cycle`) yields to a future tick.
* propagate upward.
* *
* Leaves are dispatched to a user-provided `onLeaf` callback. * Leaves are dispatched to a user-provided `onLeaf` callback.
* Terminal results are dispatched to a user-provided `onTerminal` callback. * Terminal results are dispatched to a user-provided `onTerminal` callback.
@ -90,11 +89,13 @@ function parentOf(world: World, child: Entity): Entity | null {
*/ */
export class TaskRunner { export class TaskRunner {
private _world: World; private _world: World;
private _executing = new Set<Entity>();
private _currentDt = 0;
/** Root task entity, set by `buildTree` for convenience. */ /** Root task entity, set by `buildTree` for convenience. */
root?: Entity; root?: Entity;
/** Called when a leaf task becomes Scheduled. */ /** Called when a leaf task starts executing. */
onLeaf: LeafHandler = () => {}; onLeaf: LeafHandler = () => {};
/** Called when any task reaches a terminal status. */ /** Called when any task reaches a terminal status. */
@ -107,18 +108,28 @@ export class TaskRunner {
// ── Public API ──────────────────────────────────── // ── Public API ────────────────────────────────────
/** /**
* Process all Scheduled tasks. * Process tasks scheduled for this tick.
* *
* Call once per frame. Only entities with `Scheduled` are touched. * Call once per frame. Tasks scheduled while a tick is already in progress are
* deferred until the next tick boundary, but parent/child propagation caused
* by terminal status changes is immediate.
* *
* @param dt Delta time in milliseconds since last tick. * @param dt Delta time in milliseconds since last tick.
*/ */
tick(dt: number = 0): void { tick(dt: number = 0): void {
const previousDt = this._currentDt;
this._currentDt = dt;
try {
const scheduled = [...this._world.query(query(Task, Scheduled))]; const scheduled = [...this._world.query(query(Task, Scheduled))];
for (const entity of scheduled) { for (const entity of scheduled) {
if (!this._world.has(entity, Scheduled)) continue;
this._world.remove(entity, Scheduled); this._world.remove(entity, Scheduled);
this._execute(entity, dt); this._execute(entity, dt);
} }
} finally {
this._currentDt = previousDt;
}
} }
/** Mark a leaf task as succeeded and propagate upward. */ /** Mark a leaf task as succeeded and propagate upward. */
@ -133,10 +144,10 @@ export class TaskRunner {
/** Cancel a task and all its descendants. */ /** Cancel a task and all its descendants. */
cancel(entity: Entity): void { cancel(entity: Entity): void {
this._cancelTree(entity); this._cancelTree(entity, true);
} }
/** Schedule a task for execution next tick. */ /** Schedule a task for execution at the next tick boundary. */
schedule(entity: Entity): void { schedule(entity: Entity): void {
if (this._world.has(entity, Task)) { if (this._world.has(entity, Task)) {
this._world.add(entity, Scheduled); this._world.add(entity, Scheduled);
@ -152,28 +163,51 @@ export class TaskRunner {
// ── Internal execution ──────────────────────────── // ── Internal execution ────────────────────────────
private _execute(entity: Entity, dt: number): void { private _execute(entity: Entity, dt: number): void {
const t = this._world.get(entity, Task); if (!this._world.has(entity, Task) || this._executing.has(entity)) return;
const t = this._world.get(entity, Task);
this._executing.add(entity);
try {
switch (t.kind) { switch (t.kind) {
case "leaf": case "leaf":
this._executeLeaf(entity, dt); this._executeLeaf(entity, dt);
break; break;
case "sequential": case "sequential":
this._executeSequential(entity); this._executeSequential(entity, dt);
break; break;
case "parallel": case "parallel":
this._executeParallel(entity); this._executeParallel(entity, dt);
break; break;
case "random": case "random":
this._executeRandom(entity); this._executeRandom(entity, dt);
break; break;
case "repeat": case "cycle":
this._executeRepeat(entity); this._executeCycle(entity, dt);
break; break;
case "selector": case "selector":
this._executeSelector(entity); this._executeSelector(entity, dt);
break; break;
} }
} finally {
this._executing.delete(entity);
}
}
private _executeChild(entity: Entity, dt: number): void {
if (
!this._world.has(entity, Task) ||
isTerminal(this._world, entity) ||
this._world.has(entity, Running)
) {
return;
}
if (this._world.has(entity, Scheduled)) {
this._world.remove(entity, Scheduled);
}
this._execute(entity, dt);
} }
private _executeLeaf(entity: Entity, dt: number): void { private _executeLeaf(entity: Entity, dt: number): void {
@ -181,54 +215,51 @@ export class TaskRunner {
this.onLeaf(this._world, entity, dt); this.onLeaf(this._world, entity, dt);
} }
private _executeSequential(entity: Entity): void { private _executeSequential(entity: Entity, dt: number): void {
const children = childrenOf(this._world, entity); for (const child of childrenOf(this._world, entity)) {
let status = terminalStatus(this._world, child);
// Find the first non-terminal child
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
if (status === "failed" || status === "cancelled") { if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed); this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return; return;
} }
// succeeded — continue to next child if (status === "succeeded") continue;
continue;
} this._executeChild(child, dt);
status = terminalStatus(this._world, child);
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
if (status === "succeeded") continue;
// Found a child that hasn't run yet — schedule it
this._world.add(child, Scheduled);
return; return;
} }
// All children succeeded
this._finish(entity, Succeeded); this._finish(entity, Succeeded);
} }
private _executeParallel(entity: Entity): void { private _executeParallel(entity: Entity, dt: number): void {
const children = childrenOf(this._world, entity);
let allDone = true; let allDone = true;
for (const child of children) { for (const child of childrenOf(this._world, entity)) {
if (isTerminal(this._world, child)) { let status = terminalStatus(this._world, child);
const status = terminalStatus(this._world, child)!;
if (status === "failed" || status === "cancelled") { if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed); this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return; return;
} }
// succeeded — this child is done if (status === "succeeded") continue;
continue;
this._executeChild(child, dt);
status = terminalStatus(this._world, child);
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
} }
if (status === "succeeded") continue;
allDone = false; allDone = false;
// Schedule if not already running or scheduled
if (
!this._world.has(child, Running) &&
!this._world.has(child, Scheduled)
) {
this._world.add(child, Scheduled);
}
} }
if (allDone) { if (allDone) {
@ -236,12 +267,12 @@ export class TaskRunner {
} }
} }
private _executeRandom(entity: Entity): void { private _executeRandom(entity: Entity, dt: number): void {
// Single pass: check terminals and collect eligible children
const eligible: Entity[] = []; const eligible: Entity[] = [];
for (const child of childrenOf(this._world, entity)) { for (const child of childrenOf(this._world, entity)) {
if (isTerminal(this._world, child)) { const status = terminalStatus(this._world, child);
const status = terminalStatus(this._world, child)!; if (status) {
this._finish( this._finish(
entity, entity,
status === "succeeded" status === "succeeded"
@ -252,68 +283,73 @@ export class TaskRunner {
); );
return; return;
} }
if ( if (!this._world.has(child, Running)) {
!this._world.has(child, Running) &&
!this._world.has(child, Scheduled)
) {
eligible.push(child); eligible.push(child);
} }
} }
if (eligible.length > 0) { if (eligible.length === 0) return;
const pick = eligible[Math.floor(Math.random() * eligible.length)]; const pick = eligible[Math.floor(Math.random() * eligible.length)];
this._world.add(pick, Scheduled); this._executeChild(pick, dt);
const status = terminalStatus(this._world, pick);
if (status) {
this._finish(
entity,
status === "succeeded"
? Succeeded
: status === "cancelled"
? Cancelled
: Failed,
);
} }
// If no eligible children (all running), wait for one to finish
} }
private _executeRepeat(entity: Entity): void { private _executeCycle(entity: Entity, dt: number): void {
const children = [...childrenOf(this._world, entity)]; const children = [...childrenOf(this._world, entity)];
// Repeat expects exactly one child // Cycle expects exactly one task child. Non-task child entities are ignored.
if (children.length === 0) return; if (children.length === 0) return;
const child = children[0]; const child = children[0];
// If child reached a terminal, reset the entire subtree and schedule again
if (isTerminal(this._world, child)) { if (isTerminal(this._world, child)) {
clearSubtree(this._world, child); clearSubtree(this._world, child);
this._world.add(child, Scheduled); this._world.add(child, Scheduled);
return; return;
} }
// Schedule child if not already running or scheduled this._executeChild(child, dt);
if (
!this._world.has(child, Running) && if (isTerminal(this._world, child)) {
!this._world.has(child, Scheduled) clearSubtree(this._world, child);
) {
this._world.add(child, Scheduled); this._world.add(child, Scheduled);
} }
// Repeat itself never terminates — it just keeps the child going // Cycle itself never terminates — it just creates tick-boundary cycles.
} }
private _executeSelector(entity: Entity): void { private _executeSelector(entity: Entity, dt: number): void {
const children = childrenOf(this._world, entity); for (const child of childrenOf(this._world, entity)) {
let status = terminalStatus(this._world, child);
// Find the first non-terminal child
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
if (status === "succeeded") { if (status === "succeeded") {
// First success — selector succeeds
this._finish(entity, Succeeded); this._finish(entity, Succeeded);
return; return;
} }
// failed or cancelled — continue to next child if (status === "failed" || status === "cancelled") continue;
continue;
} this._executeChild(child, dt);
status = terminalStatus(this._world, child);
if (status === "succeeded") {
this._finish(entity, Succeeded);
return;
}
if (status === "failed" || status === "cancelled") continue;
// Found a child that hasn't run yet — schedule it
this._world.add(child, Scheduled);
return; return;
} }
// All children failed
this._finish(entity, Failed); this._finish(entity, Failed);
} }
@ -331,30 +367,34 @@ export class TaskRunner {
const status = terminalStatus(this._world, entity)!; const status = terminalStatus(this._world, entity)!;
this.onTerminal(this._world, entity, status); this.onTerminal(this._world, entity, status);
// Notify parent
const parent = parentOf(this._world, entity); const parent = parentOf(this._world, entity);
if (parent) { if (parent && !this._executing.has(parent)) {
this._world.add(parent, Scheduled); if (this._world.has(parent, Scheduled)) {
this._world.remove(parent, Scheduled);
}
this._execute(parent, this._currentDt);
} }
} }
private _cancelTree(entity: Entity): void { private _cancelTree(entity: Entity, notifyParent: boolean): void {
if (!this._world.has(entity, Task)) return; if (!this._world.has(entity, Task)) return;
// Cancel children first // Cancel children first without repeatedly waking this node while its
// subtree is still being cancelled.
for (const child of childrenOf(this._world, entity)) { for (const child of childrenOf(this._world, entity)) {
this._cancelTree(child); this._cancelTree(child, false);
} }
// Cancel this node
clearStatus(this._world, entity); clearStatus(this._world, entity);
this._world.add(entity, Cancelled); this._world.add(entity, Cancelled);
this.onTerminal(this._world, entity, "cancelled"); this.onTerminal(this._world, entity, "cancelled");
// Notify parent
const parent = parentOf(this._world, entity); const parent = parentOf(this._world, entity);
if (parent) { if (notifyParent && parent && !this._executing.has(parent)) {
this._world.add(parent, Scheduled); if (this._world.has(parent, Scheduled)) {
this._world.remove(parent, Scheduled);
}
this._execute(parent, this._currentDt);
} }
} }
} }

View File

@ -9,23 +9,19 @@ import { defineRelationship } from "../relationship";
* - `"leaf"` terminal node; external logic drives it to completion. * - `"leaf"` terminal node; external logic drives it to completion.
* - `"sequential"` runs children one at a time, left to right. * - `"sequential"` runs children one at a time, left to right.
* Succeeds when all children succeed; fails when any child fails. * Succeeds when all children succeed; fails when any child fails.
* - `"parallel"` schedules all children at once. * - `"parallel"` starts all children at once.
* Succeeds when all children succeed; fails when any child fails. * Succeeds when all children succeed; fails when any child fails.
* - `"random"` picks one child at random each time it's scheduled. * - `"random"` picks one child at random each time it runs.
* Succeeds/fails with that child's result. * Succeeds/fails with that child's result.
* - `"repeat"` runs its single child. When the child finishes, resets it * - `"cycle"` runs its single child. When the child finishes, resets it
* and runs again. Never terminates on its own (only via cancel). * and schedules the next run for a future tick boundary. Never terminates
* on its own (only via cancel).
* - `"selector"` runs children left to right. Succeeds on the first * - `"selector"` runs children left to right. Succeeds on the first
* child that succeeds; fails only if all children fail. * child that succeeds; fails only if all children fail.
*/ */
export const Task = defineComponent("task", { export const Task = defineComponent("task", {
kind: "leaf" as kind: "leaf" as
| "leaf" "leaf" | "sequential" | "parallel" | "random" | "cycle" | "selector",
| "sequential"
| "parallel"
| "random"
| "repeat"
| "selector",
}); });
export type TaskKind = (typeof Task.type)["kind"]; export type TaskKind = (typeof Task.type)["kind"];

View File

@ -91,18 +91,18 @@ export function random(
} }
/** /**
* Create a repeat task entity definition. * Create a cycle task entity definition.
* *
* `repeat(child)` and `repeat([child, metadataEntity])` are both supported. * `cycle(child)` and `cycle([child, metadataEntity])` are both supported.
* The runner operates only on child entities that have the `Task` component. * The runner operates only on child entities that have the `Task` component.
*/ */
export function repeat(child: EntityDef): TaskEntityDef; export function cycle(child: EntityDef): TaskEntityDef;
export function repeat(children?: readonly EntityDefChild[]): TaskEntityDef; export function cycle(children?: readonly EntityDefChild[]): TaskEntityDef;
export function repeat( export function cycle(
childOrChildren: EntityDef | readonly EntityDefChild[] = [], childOrChildren: EntityDef | readonly EntityDefChild[] = [],
): TaskEntityDef { ): TaskEntityDef {
return task( return task(
"repeat", "cycle",
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren], Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
); );
} }
@ -114,7 +114,7 @@ export function repeat(
* fully-wired `TaskRunner`. * fully-wired `TaskRunner`.
* *
* Definitions are `EntityDef` trees produced by the task factories (`leaf`, * Definitions are `EntityDef` trees produced by the task factories (`leaf`,
* `sequential`, `parallel`, `selector`, `random`, `repeat`) and generic * `sequential`, `parallel`, `selector`, `random`, `cycle`) and generic
* single-component entity factories. Non-task child entities are materialized * single-component entity factories. Non-task child entities are materialized
* into the ECS tree but ignored by `TaskRunner` execution. * into the ECS tree but ignored by `TaskRunner` execution.
* *

View File

@ -11,7 +11,7 @@ import {
TaskRunner, TaskRunner,
buildTree, buildTree,
leaf, leaf,
repeat, cycle,
sequential, sequential,
} from "../src/bt/index"; } from "../src/bt/index";
@ -44,9 +44,9 @@ function makeRandom(world: World, parent?: Entity): Entity {
return e; return e;
} }
function makeRepeat(world: World, parent?: Entity): Entity { function makeCycle(world: World, parent?: Entity): Entity {
const e = world.spawn(); const e = world.spawn();
world.add(e, Task, { kind: "repeat" }); world.add(e, Task, { kind: "cycle" });
if (parent) world.relate(e, ChildOf, parent); if (parent) world.relate(e, ChildOf, parent);
return e; return e;
} }
@ -90,15 +90,15 @@ describe("Entity task factories", () => {
expect(world.has(root, Succeeded)).toBe(true); expect(world.has(root, Succeeded)).toBe(true);
}); });
it("repeat works with component child entities beside its task child", () => { it("cycle works with component child entities beside its task child", () => {
const Label = defineComponent("repeatLabel", { value: "" }); const Label = defineComponent("cycleLabel", { value: "" });
const world = new World(); const world = new World();
let calls = 0; let calls = 0;
const runner = buildTree( const runner = buildTree(
world, world,
repeat([ cycle([
entity(Label, { value: "repeat metadata" }), entity(Label, { value: "cycle metadata" }),
leaf(() => { leaf(() => {
calls++; calls++;
}), }),
@ -218,30 +218,20 @@ describe("Sequential tasks", () => {
runner.onLeaf = (_w, e) => leafCalls.push(e); runner.onLeaf = (_w, e) => leafCalls.push(e);
runner.schedule(seq); runner.schedule(seq);
runner.tick(); // schedules first child
expect(world.has(a, Scheduled)).toBe(true);
expect(world.has(b, Scheduled)).toBe(false);
expect(world.has(c, Scheduled)).toBe(false);
runner.tick(); // runs a
runner.succeed(a);
// parent should be re-scheduled
runner.tick(); // schedules next child
expect(world.has(b, Scheduled)).toBe(true);
runner.tick(); // runs b
runner.succeed(b);
runner.tick(); // schedules c
expect(world.has(c, Scheduled)).toBe(true);
runner.tick(); // runs c
runner.succeed(c);
// parent should now be scheduled and succeed
runner.tick(); runner.tick();
expect(world.has(a, Running)).toBe(true);
expect(world.has(b, Running)).toBe(false);
expect(world.has(c, Running)).toBe(false);
runner.succeed(a);
expect(world.has(b, Running)).toBe(true);
runner.succeed(b);
expect(world.has(c, Running)).toBe(true);
runner.succeed(c);
expect(world.has(seq, Succeeded)).toBe(true); expect(world.has(seq, Succeeded)).toBe(true);
expect(leafCalls).toEqual([a, b, c]);
}); });
it("fails immediately when a child fails", () => { it("fails immediately when a child fails", () => {
@ -319,7 +309,7 @@ describe("Parallel tasks", () => {
runner = new TaskRunner(world); runner = new TaskRunner(world);
}); });
it("schedules all children at once", () => { it("starts all children at once", () => {
const par = makeParallel(world); const par = makeParallel(world);
const a = makeLeaf(world, par); const a = makeLeaf(world, par);
const b = makeLeaf(world, par); const b = makeLeaf(world, par);
@ -328,9 +318,9 @@ describe("Parallel tasks", () => {
runner.schedule(par); runner.schedule(par);
runner.tick(); runner.tick();
expect(world.has(a, Scheduled)).toBe(true); expect(world.has(a, Running)).toBe(true);
expect(world.has(b, Scheduled)).toBe(true); expect(world.has(b, Running)).toBe(true);
expect(world.has(c, Scheduled)).toBe(true); expect(world.has(c, Running)).toBe(true);
}); });
it("succeeds when all children succeed", () => { it("succeeds when all children succeed", () => {
@ -407,15 +397,13 @@ describe("Random tasks", () => {
runner.schedule(rand); runner.schedule(rand);
runner.tick(); runner.tick();
// Exactly one child should be scheduled // Exactly one child should be running
const scheduled = [world.has(a, Scheduled), world.has(b, Scheduled)]; const running = [world.has(a, Running), world.has(b, Running)];
expect(scheduled.filter(Boolean)).toHaveLength(1); expect(running.filter(Boolean)).toHaveLength(1);
const picked = world.has(a, Scheduled) ? a : b; const picked = world.has(a, Running) ? a : b;
runner.tick(); // runs picked leaf
runner.succeed(picked); runner.succeed(picked);
runner.tick(); // random sees child done → succeeds
expect(world.has(rand, Succeeded)).toBe(true); expect(world.has(rand, Succeeded)).toBe(true);
}); });
@ -444,8 +432,8 @@ describe("Random tasks", () => {
}); });
}); });
// ── Repeat ────────────────────────────────────────── // ── Cycle ──────────────────────────────────────────
describe("Repeat tasks", () => { describe("Cycle tasks", () => {
let world: World; let world: World;
let runner: TaskRunner; let runner: TaskRunner;
@ -455,110 +443,98 @@ describe("Repeat tasks", () => {
}); });
it("re-runs child after it succeeds", () => { it("re-runs child after it succeeds", () => {
const rep = makeRepeat(world); const cyc = makeCycle(world);
const leaf = makeLeaf(world, rep); const leaf = makeLeaf(world, cyc);
let leafCount = 0; let leafCount = 0;
runner.onLeaf = () => leafCount++; runner.onLeaf = () => leafCount++;
runner.schedule(rep); runner.schedule(cyc);
// First run runner.tick();
runner.tick(); // repeat schedules leaf
runner.tick(); // leaf runs
expect(leafCount).toBe(1); expect(leafCount).toBe(1);
runner.succeed(leaf); runner.succeed(leaf);
// Repeat re-scheduled // Completion schedules the next cycle at a tick boundary.
runner.tick(); // repeat sees leaf done, resets and schedules again expect(world.has(leaf, Scheduled)).toBe(true);
runner.tick(); // leaf runs again runner.tick();
expect(leafCount).toBe(2); expect(leafCount).toBe(2);
runner.succeed(leaf); runner.succeed(leaf);
// And again runner.tick();
runner.tick(); // repeat resets
runner.tick(); // leaf runs
expect(leafCount).toBe(3); expect(leafCount).toBe(3);
}); });
it("re-runs child after it fails", () => { it("re-runs child after it fails", () => {
const rep = makeRepeat(world); const cyc = makeCycle(world);
const leaf = makeLeaf(world, rep); const leaf = makeLeaf(world, cyc);
let leafCount = 0; let leafCount = 0;
runner.onLeaf = () => leafCount++; runner.onLeaf = () => leafCount++;
runner.schedule(rep); runner.schedule(cyc);
runner.tick(); // repeat schedules leaf runner.tick();
runner.tick(); // leaf runs
runner.fail(leaf); runner.fail(leaf);
// Repeat re-scheduled, resets leaf expect(world.has(leaf, Scheduled)).toBe(true);
runner.tick(); // repeat resets leaf runner.tick();
runner.tick(); // leaf runs again
expect(leafCount).toBe(2); expect(leafCount).toBe(2);
}); });
it("never terminates on its own", () => { it("never terminates on its own", () => {
const rep = makeRepeat(world); const cyc = makeCycle(world);
const leaf = makeLeaf(world, rep); const leaf = makeLeaf(world, cyc);
runner.schedule(rep); runner.schedule(cyc);
runner.tick(); // schedules leaf runner.tick();
runner.tick(); // runs leaf
runner.succeed(leaf); runner.succeed(leaf);
// After many cycles, repeat is still not terminal // After many cycles, cycle is still not terminal
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
runner.tick(); // repeat resets + schedules leaf runner.tick();
runner.tick(); // leaf runs
runner.succeed(leaf); runner.succeed(leaf);
} }
expect(world.has(rep, Succeeded)).toBe(false); expect(world.has(cyc, Succeeded)).toBe(false);
expect(world.has(rep, Failed)).toBe(false); expect(world.has(cyc, Failed)).toBe(false);
expect(world.has(rep, Cancelled)).toBe(false); expect(world.has(cyc, Cancelled)).toBe(false);
}); });
it("can be cancelled", () => { it("can be cancelled", () => {
const rep = makeRepeat(world); const cyc = makeCycle(world);
const leaf = makeLeaf(world, rep); const leaf = makeLeaf(world, cyc);
runner.schedule(rep); runner.schedule(cyc);
runner.tick(); // schedules leaf runner.tick();
runner.tick(); // runs leaf
runner.cancel(rep); runner.cancel(cyc);
expect(world.has(rep, Cancelled)).toBe(true); expect(world.has(cyc, Cancelled)).toBe(true);
expect(world.has(leaf, Cancelled)).toBe(true); expect(world.has(leaf, Cancelled)).toBe(true);
}); });
it("empty repeat does nothing", () => { it("empty cycle does nothing", () => {
const rep = makeRepeat(world); const cyc = makeCycle(world);
runner.schedule(rep); runner.schedule(cyc);
runner.tick(); runner.tick();
// No child, so nothing happens // No child, so nothing happens
expect(world.has(rep, Succeeded)).toBe(false); expect(world.has(cyc, Succeeded)).toBe(false);
expect(world.has(rep, Failed)).toBe(false); expect(world.has(cyc, Failed)).toBe(false);
}); });
it("repeat inside sequential advances parent when cancelled", () => { it("cycle inside sequential advances parent when cancelled", () => {
const seq = makeSequential(world); const seq = makeSequential(world);
const rep = makeRepeat(world, seq); const cyc = makeCycle(world, seq);
const leaf = makeLeaf(world, rep); const leaf = makeLeaf(world, cyc);
const after = makeLeaf(world, seq); const after = makeLeaf(world, seq);
runner.schedule(seq); runner.schedule(seq);
runner.tick(); // seq schedules rep runner.tick();
runner.tick(); // rep schedules leaf
runner.tick(); // leaf runs
// Cancel the repeat // Cancel the cycle
runner.cancel(rep); runner.cancel(cyc);
runner.tick(); // seq sees rep cancelled, seq becomes cancelled
expect(world.has(seq, Cancelled)).toBe(true); expect(world.has(seq, Cancelled)).toBe(true);
expect(world.has(after, Scheduled)).toBe(false); expect(world.has(after, Scheduled)).toBe(false);
@ -604,19 +580,12 @@ describe("Selector tasks", () => {
runner.tick(); // runs a runner.tick(); // runs a
runner.fail(a); runner.fail(a);
runner.tick(); // selector schedules b expect(world.has(b, Running)).toBe(true);
expect(world.has(b, Scheduled)).toBe(true);
runner.tick(); // runs b
runner.fail(b); runner.fail(b);
expect(world.has(c, Running)).toBe(true);
runner.tick(); // selector schedules c
expect(world.has(c, Scheduled)).toBe(true);
runner.tick(); // runs c
runner.succeed(c); runner.succeed(c);
runner.tick(); // selector succeeds
expect(world.has(sel, Succeeded)).toBe(true); expect(world.has(sel, Succeeded)).toBe(true);
}); });
@ -656,8 +625,7 @@ describe("Selector tasks", () => {
runner.tick(); // runs a runner.tick(); // runs a
runner.cancel(a); runner.cancel(a);
runner.tick(); // selector sees a cancelled, tries b expect(world.has(b, Running)).toBe(true);
expect(world.has(b, Scheduled)).toBe(true);
}); });
}); });
@ -756,10 +724,9 @@ describe("Multi-frame leaves", () => {
expect(world.has(b, Scheduled)).toBe(false); expect(world.has(b, Scheduled)).toBe(false);
expect(world.has(b, Running)).toBe(false); expect(world.has(b, Running)).toBe(false);
// Finish a // Finish a; parent propagation starts b immediately.
runner.succeed(a); runner.succeed(a);
runner.tick(); // seq schedules b expect(world.has(b, Running)).toBe(true);
expect(world.has(b, Scheduled)).toBe(true);
}); });
}); });
@ -804,25 +771,11 @@ describe("Edge cases", () => {
runner.schedule(root); runner.schedule(root);
// Tick 1: root schedules mid
runner.tick();
expect(world.has(mid, Scheduled)).toBe(true);
// Tick 2: mid schedules leaf
runner.tick();
expect(world.has(leaf, Scheduled)).toBe(true);
// Tick 3: leaf runs
runner.tick(); runner.tick();
expect(world.has(leaf, Running)).toBe(true); expect(world.has(leaf, Running)).toBe(true);
runner.succeed(leaf); runner.succeed(leaf);
// leaf done → mid scheduled
runner.tick(); // mid sees leaf done → mid succeeds
expect(world.has(mid, Succeeded)).toBe(true); expect(world.has(mid, Succeeded)).toBe(true);
runner.tick(); // root sees mid done → root succeeds
expect(world.has(root, Succeeded)).toBe(true); expect(world.has(root, Succeeded)).toBe(true);
}); });
}); });