From 204b9100e6b45e0b2e111c19571405dde0bedfcf Mon Sep 17 00:00:00 2001 From: hyper Date: Sun, 28 Jun 2026 14:51:52 +0800 Subject: [PATCH] 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. --- USAGE.md | 6 +- examples/blackjack/main.ts | 6 +- examples/tetris/main.ts | 6 +- src/bt/index.ts | 2 +- src/bt/runner.ts | 274 +++++++++++++++++++++---------------- src/bt/task.ts | 16 +-- src/bt/tree-def.ts | 14 +- test/bt.test.ts | 195 ++++++++++---------------- 8 files changed, 254 insertions(+), 265 deletions(-) diff --git a/USAGE.md b/USAGE.md index 94b4dc9..1092b55 100644 --- a/USAGE.md +++ b/USAGE.md @@ -397,7 +397,7 @@ 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, repeat, sequential } from "ecs-observable/bt"; +import { buildTree, Cancel, leaf, parallel, cycle, sequential } from "ecs-observable/bt"; ``` #### 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 parallel([a, b, c]) // all at once, all must succeed 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: @@ -464,7 +464,7 @@ const runner = buildTree( updatePhysics(dt); } }), - repeat( + cycle( sequential([ leaf(() => { handleInput(); }), leaf(() => { render(); }), diff --git a/examples/blackjack/main.ts b/examples/blackjack/main.ts index 3b5f61f..cc2104b 100644 --- a/examples/blackjack/main.ts +++ b/examples/blackjack/main.ts @@ -4,7 +4,7 @@ // Behaviour Tree (buildTree) — controls game flow: // parallel // ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand -// └── repeat +// └── cycle // └── seq (sequential) // ├── handleInput (leaf) — reads queued commands // └── render (leaf) — draws via blessed @@ -27,7 +27,7 @@ import { buildTree, leaf, parallel, - repeat, + cycle, sequential, } from "../../src/bt/index"; import { CommandQueue } from "../../src/commands/index"; @@ -88,7 +88,7 @@ const runner = buildTree( } } }), - repeat( + cycle( sequential([ leaf(() => { commands.execute(); diff --git a/examples/tetris/main.ts b/examples/tetris/main.ts index 562ad29..780f8df 100644 --- a/examples/tetris/main.ts +++ b/examples/tetris/main.ts @@ -4,7 +4,7 @@ // Behaviour Tree (buildTree) — controls game flow: // parallel // ├── gravityTick (leaf) — generator loop, auto-drop piece on timer -// └── repeat +// └── cycle // └── seq (sequential) // ├── handleInput (leaf) — reads queued commands // └── render (leaf) — draws via blessed @@ -24,7 +24,7 @@ import { buildTree, leaf, parallel, - repeat, + cycle, sequential, } from "../../src/bt/index"; import { CommandQueue } from "../../src/commands/index"; @@ -95,7 +95,7 @@ const runner = buildTree( } } }), - repeat( + cycle( sequential([ leaf(() => { commands.execute(); diff --git a/src/bt/index.ts b/src/bt/index.ts index f720ea1..57002cb 100644 --- a/src/bt/index.ts +++ b/src/bt/index.ts @@ -21,6 +21,6 @@ export { parallel, selector, random, - repeat, + cycle, } from "./tree-def"; export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def"; diff --git a/src/bt/runner.ts b/src/bt/runner.ts index a1883a2..1af1ddd 100644 --- a/src/bt/runner.ts +++ b/src/bt/runner.ts @@ -12,7 +12,7 @@ import { } from "./task"; // ── 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; /** 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. * - * Only tasks tagged with `Scheduled` are processed each tick. - * When a task finishes, it notifies its parent, which may schedule - * the next child (sequential), aggregate results (parallel), or - * propagate upward. + * 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. * * Leaves are dispatched to a user-provided `onLeaf` 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 { private _world: World; + private _executing = new Set(); + private _currentDt = 0; /** Root task entity, set by `buildTree` for convenience. */ root?: Entity; - /** Called when a leaf task becomes Scheduled. */ + /** Called when a leaf task starts executing. */ onLeaf: LeafHandler = () => {}; /** Called when any task reaches a terminal status. */ @@ -107,17 +108,27 @@ export class TaskRunner { // ── 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. */ tick(dt: number = 0): void { - const scheduled = [...this._world.query(query(Task, Scheduled))]; - for (const entity of scheduled) { - this._world.remove(entity, Scheduled); - this._execute(entity, dt); + const previousDt = this._currentDt; + this._currentDt = dt; + + try { + const scheduled = [...this._world.query(query(Task, Scheduled))]; + for (const entity of scheduled) { + if (!this._world.has(entity, Scheduled)) continue; + this._world.remove(entity, Scheduled); + this._execute(entity, dt); + } + } finally { + this._currentDt = previousDt; } } @@ -133,10 +144,10 @@ export class TaskRunner { /** Cancel a task and all its descendants. */ 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 { if (this._world.has(entity, Task)) { this._world.add(entity, Scheduled); @@ -152,83 +163,103 @@ export class TaskRunner { // ── Internal execution ──────────────────────────── 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; - switch (t.kind) { - case "leaf": - this._executeLeaf(entity, dt); - break; - case "sequential": - this._executeSequential(entity); - break; - case "parallel": - this._executeParallel(entity); - break; - case "random": - this._executeRandom(entity); - break; - case "repeat": - this._executeRepeat(entity); - break; - case "selector": - this._executeSelector(entity); - break; + const t = this._world.get(entity, Task); + this._executing.add(entity); + + try { + switch (t.kind) { + case "leaf": + this._executeLeaf(entity, dt); + break; + case "sequential": + this._executeSequential(entity, dt); + break; + case "parallel": + this._executeParallel(entity, dt); + break; + case "random": + this._executeRandom(entity, dt); + break; + case "cycle": + this._executeCycle(entity, dt); + break; + case "selector": + this._executeSelector(entity, dt); + 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 { this._world.add(entity, Running); this.onLeaf(this._world, entity, dt); } - private _executeSequential(entity: Entity): void { - const children = childrenOf(this._world, entity); - - // Find the first non-terminal child - for (const child of children) { - if (isTerminal(this._world, child)) { - const status = terminalStatus(this._world, child)!; - if (status === "failed" || status === "cancelled") { - this._finish(entity, status === "cancelled" ? Cancelled : Failed); - return; - } - // succeeded — continue to next child - continue; + private _executeSequential(entity: Entity, dt: number): void { + for (const child of childrenOf(this._world, entity)) { + let status = terminalStatus(this._world, child); + if (status === "failed" || status === "cancelled") { + this._finish(entity, status === "cancelled" ? Cancelled : Failed); + return; } + if (status === "succeeded") 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; } - // All children succeeded this._finish(entity, Succeeded); } - private _executeParallel(entity: Entity): void { - const children = childrenOf(this._world, entity); + private _executeParallel(entity: Entity, dt: number): void { let allDone = true; - for (const child of children) { - if (isTerminal(this._world, child)) { - const status = terminalStatus(this._world, child)!; - if (status === "failed" || status === "cancelled") { - this._finish(entity, status === "cancelled" ? Cancelled : Failed); - return; - } - // succeeded — this child is done - continue; + for (const child of childrenOf(this._world, entity)) { + let status = terminalStatus(this._world, child); + if (status === "failed" || status === "cancelled") { + this._finish(entity, status === "cancelled" ? Cancelled : Failed); + return; } + if (status === "succeeded") 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; - - // 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) { @@ -236,12 +267,12 @@ export class TaskRunner { } } - private _executeRandom(entity: Entity): void { - // Single pass: check terminals and collect eligible children + private _executeRandom(entity: Entity, dt: number): void { const eligible: 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( entity, status === "succeeded" @@ -252,68 +283,73 @@ export class TaskRunner { ); return; } - if ( - !this._world.has(child, Running) && - !this._world.has(child, Scheduled) - ) { + if (!this._world.has(child, Running)) { eligible.push(child); } } - if (eligible.length > 0) { - const pick = eligible[Math.floor(Math.random() * eligible.length)]; - this._world.add(pick, Scheduled); + if (eligible.length === 0) return; + + const pick = eligible[Math.floor(Math.random() * eligible.length)]; + 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)]; - // Repeat expects exactly one child + // Cycle expects exactly one task child. Non-task child entities are ignored. if (children.length === 0) return; const child = children[0]; - // If child reached a terminal, reset the entire subtree and schedule again if (isTerminal(this._world, child)) { clearSubtree(this._world, child); this._world.add(child, Scheduled); return; } - // Schedule child if not already running or scheduled - if ( - !this._world.has(child, Running) && - !this._world.has(child, Scheduled) - ) { + this._executeChild(child, dt); + + if (isTerminal(this._world, child)) { + clearSubtree(this._world, child); 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 { - const children = childrenOf(this._world, entity); - - // Find the first non-terminal child - for (const child of children) { - if (isTerminal(this._world, child)) { - const status = terminalStatus(this._world, child)!; - if (status === "succeeded") { - // First success — selector succeeds - this._finish(entity, Succeeded); - return; - } - // failed or cancelled — continue to next child - continue; + private _executeSelector(entity: Entity, dt: number): void { + for (const child of childrenOf(this._world, entity)) { + let status = terminalStatus(this._world, child); + if (status === "succeeded") { + this._finish(entity, Succeeded); + return; } + if (status === "failed" || status === "cancelled") 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; } - // All children failed this._finish(entity, Failed); } @@ -331,30 +367,34 @@ export class TaskRunner { const status = terminalStatus(this._world, entity)!; this.onTerminal(this._world, entity, status); - // Notify parent const parent = parentOf(this._world, entity); - if (parent) { - this._world.add(parent, Scheduled); + if (parent && !this._executing.has(parent)) { + 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; - // 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)) { - this._cancelTree(child); + this._cancelTree(child, false); } - // Cancel this node clearStatus(this._world, entity); this._world.add(entity, Cancelled); this.onTerminal(this._world, entity, "cancelled"); - // Notify parent const parent = parentOf(this._world, entity); - if (parent) { - this._world.add(parent, Scheduled); + if (notifyParent && parent && !this._executing.has(parent)) { + if (this._world.has(parent, Scheduled)) { + this._world.remove(parent, Scheduled); + } + this._execute(parent, this._currentDt); } } } diff --git a/src/bt/task.ts b/src/bt/task.ts index 1235a12..d002f4d 100644 --- a/src/bt/task.ts +++ b/src/bt/task.ts @@ -9,23 +9,19 @@ import { defineRelationship } from "../relationship"; * - `"leaf"` — terminal node; external logic drives it to completion. * - `"sequential"` — runs children one at a time, left to right. * Succeeds when all children succeed; fails when any child fails. - * - `"parallel"` — schedules all children at once. + * - `"parallel"` — starts all children at once. * Succeeds when all children succeed; fails when any child fails. - * - `"random"` — picks one child at random each time it's scheduled. + * - `"random"` — picks one child at random each time it runs. * Succeeds/fails with that child's result. - * - `"repeat"` — runs its single child. When the child finishes, resets it - * and runs again. Never terminates on its own (only via cancel). + * - `"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). * - `"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" - | "repeat" - | "selector", + "leaf" | "sequential" | "parallel" | "random" | "cycle" | "selector", }); export type TaskKind = (typeof Task.type)["kind"]; diff --git a/src/bt/tree-def.ts b/src/bt/tree-def.ts index 1b7eef4..86f74a9 100644 --- a/src/bt/tree-def.ts +++ b/src/bt/tree-def.ts @@ -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. */ -export function repeat(child: EntityDef): TaskEntityDef; -export function repeat(children?: readonly EntityDefChild[]): TaskEntityDef; -export function repeat( +export function cycle(child: EntityDef): TaskEntityDef; +export function cycle(children?: readonly EntityDefChild[]): TaskEntityDef; +export function cycle( childOrChildren: EntityDef | readonly EntityDefChild[] = [], ): TaskEntityDef { return task( - "repeat", + "cycle", Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren], ); } @@ -114,7 +114,7 @@ export function repeat( * fully-wired `TaskRunner`. * * 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 * into the ECS tree but ignored by `TaskRunner` execution. * diff --git a/test/bt.test.ts b/test/bt.test.ts index 1b27642..b0153a5 100644 --- a/test/bt.test.ts +++ b/test/bt.test.ts @@ -11,7 +11,7 @@ import { TaskRunner, buildTree, leaf, - repeat, + cycle, sequential, } from "../src/bt/index"; @@ -44,9 +44,9 @@ function makeRandom(world: World, parent?: Entity): Entity { return e; } -function makeRepeat(world: World, parent?: Entity): Entity { +function makeCycle(world: World, parent?: Entity): Entity { const e = world.spawn(); - world.add(e, Task, { kind: "repeat" }); + world.add(e, Task, { kind: "cycle" }); if (parent) world.relate(e, ChildOf, parent); return e; } @@ -90,15 +90,15 @@ describe("Entity task factories", () => { expect(world.has(root, Succeeded)).toBe(true); }); - it("repeat works with component child entities beside its task child", () => { - const Label = defineComponent("repeatLabel", { value: "" }); + it("cycle works with component child entities beside its task child", () => { + const Label = defineComponent("cycleLabel", { value: "" }); const world = new World(); let calls = 0; const runner = buildTree( world, - repeat([ - entity(Label, { value: "repeat metadata" }), + cycle([ + entity(Label, { value: "cycle metadata" }), leaf(() => { calls++; }), @@ -218,30 +218,20 @@ describe("Sequential tasks", () => { runner.onLeaf = (_w, e) => leafCalls.push(e); runner.schedule(seq); - runner.tick(); // schedules first child - expect(world.has(a, Scheduled)).toBe(true); - expect(world.has(b, Scheduled)).toBe(false); - expect(world.has(c, Scheduled)).toBe(false); - - runner.tick(); // runs a - runner.succeed(a); - - // parent should be re-scheduled - runner.tick(); // schedules next child - expect(world.has(b, Scheduled)).toBe(true); - - runner.tick(); // runs b - runner.succeed(b); - - runner.tick(); // schedules c - expect(world.has(c, Scheduled)).toBe(true); - - runner.tick(); // runs c - runner.succeed(c); - - // parent should now be scheduled and succeed runner.tick(); + expect(world.has(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(leafCalls).toEqual([a, b, c]); }); it("fails immediately when a child fails", () => { @@ -319,7 +309,7 @@ describe("Parallel tasks", () => { runner = new TaskRunner(world); }); - it("schedules all children at once", () => { + it("starts all children at once", () => { const par = makeParallel(world); const a = makeLeaf(world, par); const b = makeLeaf(world, par); @@ -328,9 +318,9 @@ describe("Parallel tasks", () => { runner.schedule(par); runner.tick(); - expect(world.has(a, Scheduled)).toBe(true); - expect(world.has(b, Scheduled)).toBe(true); - expect(world.has(c, Scheduled)).toBe(true); + expect(world.has(a, Running)).toBe(true); + expect(world.has(b, Running)).toBe(true); + expect(world.has(c, Running)).toBe(true); }); it("succeeds when all children succeed", () => { @@ -407,15 +397,13 @@ describe("Random tasks", () => { runner.schedule(rand); runner.tick(); - // Exactly one child should be scheduled - const scheduled = [world.has(a, Scheduled), world.has(b, Scheduled)]; - expect(scheduled.filter(Boolean)).toHaveLength(1); + // Exactly one child should be running + const running = [world.has(a, Running), world.has(b, Running)]; + expect(running.filter(Boolean)).toHaveLength(1); - const picked = world.has(a, Scheduled) ? a : b; - runner.tick(); // runs picked leaf + const picked = world.has(a, Running) ? a : b; runner.succeed(picked); - runner.tick(); // random sees child done → succeeds expect(world.has(rand, Succeeded)).toBe(true); }); @@ -444,8 +432,8 @@ describe("Random tasks", () => { }); }); -// ── Repeat ────────────────────────────────────────── -describe("Repeat tasks", () => { +// ── Cycle ────────────────────────────────────────── +describe("Cycle tasks", () => { let world: World; let runner: TaskRunner; @@ -455,110 +443,98 @@ describe("Repeat tasks", () => { }); it("re-runs child after it succeeds", () => { - const rep = makeRepeat(world); - const leaf = makeLeaf(world, rep); + const cyc = makeCycle(world); + const leaf = makeLeaf(world, cyc); let leafCount = 0; runner.onLeaf = () => leafCount++; - runner.schedule(rep); + runner.schedule(cyc); - // First run - runner.tick(); // repeat schedules leaf - runner.tick(); // leaf runs + runner.tick(); expect(leafCount).toBe(1); runner.succeed(leaf); - // Repeat re-scheduled - runner.tick(); // repeat sees leaf done, resets and schedules again - runner.tick(); // leaf runs again + // Completion schedules the next cycle at a tick boundary. + expect(world.has(leaf, Scheduled)).toBe(true); + runner.tick(); expect(leafCount).toBe(2); runner.succeed(leaf); - // And again - runner.tick(); // repeat resets - runner.tick(); // leaf runs + runner.tick(); expect(leafCount).toBe(3); }); it("re-runs child after it fails", () => { - const rep = makeRepeat(world); - const leaf = makeLeaf(world, rep); + const cyc = makeCycle(world); + const leaf = makeLeaf(world, cyc); let leafCount = 0; runner.onLeaf = () => leafCount++; - runner.schedule(rep); - runner.tick(); // repeat schedules leaf - runner.tick(); // leaf runs + runner.schedule(cyc); + runner.tick(); runner.fail(leaf); - // Repeat re-scheduled, resets leaf - runner.tick(); // repeat resets leaf - runner.tick(); // leaf runs again + expect(world.has(leaf, Scheduled)).toBe(true); + runner.tick(); expect(leafCount).toBe(2); }); it("never terminates on its own", () => { - const rep = makeRepeat(world); - const leaf = makeLeaf(world, rep); + const cyc = makeCycle(world); + const leaf = makeLeaf(world, cyc); - runner.schedule(rep); - runner.tick(); // schedules leaf - runner.tick(); // runs leaf + runner.schedule(cyc); + runner.tick(); 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++) { - runner.tick(); // repeat resets + schedules leaf - runner.tick(); // leaf runs + runner.tick(); runner.succeed(leaf); } - expect(world.has(rep, Succeeded)).toBe(false); - expect(world.has(rep, Failed)).toBe(false); - expect(world.has(rep, Cancelled)).toBe(false); + expect(world.has(cyc, Succeeded)).toBe(false); + expect(world.has(cyc, Failed)).toBe(false); + expect(world.has(cyc, Cancelled)).toBe(false); }); it("can be cancelled", () => { - const rep = makeRepeat(world); - const leaf = makeLeaf(world, rep); + const cyc = makeCycle(world); + const leaf = makeLeaf(world, cyc); - runner.schedule(rep); - runner.tick(); // schedules leaf - runner.tick(); // runs leaf + runner.schedule(cyc); + runner.tick(); - 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); }); - it("empty repeat does nothing", () => { - const rep = makeRepeat(world); + it("empty cycle does nothing", () => { + const cyc = makeCycle(world); - runner.schedule(rep); + runner.schedule(cyc); runner.tick(); // No child, so nothing happens - expect(world.has(rep, Succeeded)).toBe(false); - expect(world.has(rep, Failed)).toBe(false); + expect(world.has(cyc, Succeeded)).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 rep = makeRepeat(world, seq); - const leaf = makeLeaf(world, rep); + const cyc = makeCycle(world, seq); + const leaf = makeLeaf(world, cyc); const after = makeLeaf(world, seq); runner.schedule(seq); - runner.tick(); // seq schedules rep - runner.tick(); // rep schedules leaf - runner.tick(); // leaf runs + runner.tick(); - // Cancel the repeat - runner.cancel(rep); - runner.tick(); // seq sees rep cancelled, seq becomes cancelled + // Cancel the cycle + runner.cancel(cyc); expect(world.has(seq, Cancelled)).toBe(true); expect(world.has(after, Scheduled)).toBe(false); @@ -604,19 +580,12 @@ describe("Selector tasks", () => { runner.tick(); // runs a runner.fail(a); - runner.tick(); // selector schedules b - expect(world.has(b, Scheduled)).toBe(true); + expect(world.has(b, Running)).toBe(true); - runner.tick(); // runs 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.tick(); // selector succeeds expect(world.has(sel, Succeeded)).toBe(true); }); @@ -656,8 +625,7 @@ describe("Selector tasks", () => { runner.tick(); // runs a runner.cancel(a); - runner.tick(); // selector sees a cancelled, tries b - expect(world.has(b, Scheduled)).toBe(true); + expect(world.has(b, Running)).toBe(true); }); }); @@ -756,10 +724,9 @@ describe("Multi-frame leaves", () => { expect(world.has(b, Scheduled)).toBe(false); expect(world.has(b, Running)).toBe(false); - // Finish a + // Finish a; parent propagation starts b immediately. runner.succeed(a); - runner.tick(); // seq schedules b - expect(world.has(b, Scheduled)).toBe(true); + expect(world.has(b, Running)).toBe(true); }); }); @@ -804,25 +771,11 @@ describe("Edge cases", () => { runner.schedule(root); - // Tick 1: root schedules mid - runner.tick(); - expect(world.has(mid, Scheduled)).toBe(true); - - // Tick 2: mid schedules leaf - runner.tick(); - expect(world.has(leaf, Scheduled)).toBe(true); - - // Tick 3: leaf runs runner.tick(); expect(world.has(leaf, Running)).toBe(true); runner.succeed(leaf); - // leaf done → mid scheduled - - runner.tick(); // mid sees leaf done → mid succeeds expect(world.has(mid, Succeeded)).toBe(true); - - runner.tick(); // root sees mid done → root succeeds expect(world.has(root, Succeeded)).toBe(true); }); });