import { describe, it, expect, beforeEach } from "vitest"; import { World, defineComponent, entity, type Entity } from "../src/index"; import { Task, Scheduled, Running, Succeeded, Failed, Cancelled, ChildOf, TaskRunner, buildTree, Cancel, action, wait, cycle, whilst, sequential, } from "../src/bt/index"; // ── Helpers ───────────────────────────────────────── function makeWait(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "wait" }); if (parent) world.relate(e, ChildOf, parent); return e; } function makeSequential(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "sequential" }); if (parent) world.relate(e, ChildOf, parent); return e; } function makeParallel(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "parallel" }); if (parent) world.relate(e, ChildOf, parent); return e; } function makeRandom(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "random" }); if (parent) world.relate(e, ChildOf, parent); return e; } function makeCycle(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "cycle" }); if (parent) world.relate(e, ChildOf, parent); return e; } function makeSelector(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "selector" }); if (parent) world.relate(e, ChildOf, parent); return e; } // ── Entity task factories ─────────────────────────── describe("Entity task factories", () => { it("materializes non-task child entities and ignores them during execution", () => { const Label = defineComponent("testLabel", { value: "" }); const world = new World(); const calls: string[] = []; const runner = buildTree( world, sequential([ entity(Label, { value: "sequence metadata" }), action(() => calls.push("a")), entity(Label, { value: "between leaves" }), action(() => calls.push("b")), ]), ); const root = runner.root!; const children = [...world.getRelatedTo(root, ChildOf)]; const labels = children .filter((child) => world.has(child, Label)) .map((child) => world.get(child, Label).value); expect(labels).toEqual(["sequence metadata", "between leaves"]); runner.schedule(root); for (let i = 0; i < 5; i++) runner.tick(); expect(calls).toEqual(["a", "b"]); expect(world.has(root, Succeeded)).toBe(true); }); it("action succeeds immediately when it returns", () => { const world = new World(); let receivedDt = 0; let receivedEntity: Entity | undefined; const runner = buildTree( world, action((_world, entity, dt) => { receivedEntity = entity; receivedDt = dt; }), ); runner.schedule(runner.root!); runner.tick(16); expect(receivedEntity).toBe(runner.root); expect(receivedDt).toBe(16); expect(world.has(runner.root!, Succeeded)).toBe(true); }); it("action fails or cancels when it throws", () => { const world = new World(); const failed = buildTree( world, action(() => { throw new Error("bad"); }), ); const cancelled = buildTree( world, action(() => { throw Cancel; }), ); failed.schedule(failed.root!); failed.tick(); cancelled.schedule(cancelled.root!); cancelled.tick(); expect(world.has(failed.root!, Failed)).toBe(true); expect(world.has(cancelled.root!, Cancelled)).toBe(true); }); it("wait can complete itself through task control", () => { const world = new World(); const runner = buildTree( world, wait((_world, _entity, task) => { task.succeed(); }), ); runner.schedule(runner.root!); runner.tick(); expect(world.has(runner.root!, Succeeded)).toBe(true); }); it("wait without a starter remains running", () => { const world = new World(); const runner = buildTree(world, wait()); runner.schedule(runner.root!); runner.tick(); expect(world.has(runner.root!, Running)).toBe(true); }); it("whilst loops at tick boundaries until its condition is false", () => { const world = new World(); let count = 0; const runner = buildTree( world, whilst( () => count < 3, action(() => { count++; }), ), ); runner.schedule(runner.root!); runner.tick(); expect(count).toBe(1); expect(world.has(runner.root!, Scheduled)).toBe(true); runner.tick(); expect(count).toBe(2); runner.tick(); expect(count).toBe(3); runner.tick(); expect(world.has(runner.root!, Succeeded)).toBe(true); }); it("whilst propagates child failure", () => { const world = new World(); const runner = buildTree( world, whilst( () => true, action(() => { throw new Error("bad"); }), ), ); runner.schedule(runner.root!); runner.tick(); expect(world.has(runner.root!, Failed)).toBe(true); }); it("cycle works with component child entities beside its task child", () => { const Label = defineComponent("cycleLabel", { value: "" }); const world = new World(); let calls = 0; const runner = buildTree( world, cycle([ entity(Label, { value: "cycle metadata" }), action(() => { calls++; }), ]), ); runner.schedule(runner.root!); for (let i = 0; i < 5; i++) runner.tick(); expect(calls).toBeGreaterThan(1); }); }); // ── Wait tasks ────────────────────────────────────── describe("Wait tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("calls onWait when a wait task is scheduled and ticked", () => { const action = makeWait(world); const calls: Entity[] = []; runner.onWait = (_w, e) => calls.push(e); runner.schedule(action); runner.tick(); expect(calls).toEqual([action]); }); it("marks wait task as Running after tick", () => { const action = makeWait(world); runner.schedule(action); runner.tick(); expect(world.has(action, Running)).toBe(true); expect(world.has(action, Scheduled)).toBe(false); }); it("succeed() marks wait task as Succeeded", () => { const action = makeWait(world); runner.schedule(action); runner.tick(); runner.succeed(action); expect(world.has(action, Succeeded)).toBe(true); expect(world.has(action, Running)).toBe(false); }); it("fail() marks wait task as Failed", () => { const action = makeWait(world); runner.schedule(action); runner.tick(); runner.fail(action); expect(world.has(action, Failed)).toBe(true); expect(world.has(action, Running)).toBe(false); }); it("cancel() marks wait task as Cancelled", () => { const action = makeWait(world); runner.schedule(action); runner.tick(); runner.cancel(action); expect(world.has(action, Cancelled)).toBe(true); expect(world.has(action, Running)).toBe(false); }); it("onTerminal is called when wait task finishes", () => { const action = makeWait(world); const terminals: { entity: Entity; status: string }[] = []; runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s }); runner.schedule(action); runner.tick(); runner.succeed(action); expect(terminals).toEqual([{ entity: action, status: "succeeded" }]); }); it("reset() clears all status tags", () => { const action = makeWait(world); runner.schedule(action); runner.tick(); runner.succeed(action); runner.reset(action); expect(world.has(action, Succeeded)).toBe(false); expect(world.has(action, Running)).toBe(false); expect(world.has(action, Scheduled)).toBe(false); }); }); // ── Sequential ────────────────────────────────────── describe("Sequential tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("runs children one at a time in order", () => { const seq = makeSequential(world); const a = makeWait(world, seq); const b = makeWait(world, seq); const c = makeWait(world, seq); const leafCalls: Entity[] = []; runner.onWait = (_w, e) => leafCalls.push(e); runner.schedule(seq); 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", () => { const seq = makeSequential(world); const a = makeWait(world, seq); const b = makeWait(world, seq); runner.schedule(seq); runner.tick(); // schedules a runner.tick(); // runs a runner.fail(a); // parent re-scheduled runner.tick(); // sees a failed → seq fails expect(world.has(seq, Failed)).toBe(true); // b was never touched expect(world.has(b, Scheduled)).toBe(false); expect(world.has(b, Running)).toBe(false); }); it("succeeds when all children succeed", () => { const seq = makeSequential(world); const a = makeWait(world, seq); const b = makeWait(world, seq); runner.schedule(seq); runner.tick(); // schedules a runner.tick(); // runs a runner.succeed(a); runner.tick(); // schedules b runner.tick(); // runs b runner.succeed(b); runner.tick(); // seq succeeds expect(world.has(seq, Succeeded)).toBe(true); }); it("propagates terminal to grandparent", () => { const root = makeSequential(world); const child = makeSequential(world, root); const action = makeWait(world, child); const terminals: Entity[] = []; runner.onTerminal = (_w, e) => terminals.push(e); runner.schedule(root); runner.tick(); // schedules child runner.tick(); // schedules action runner.tick(); // runs action runner.succeed(action); runner.tick(); // child succeeds runner.tick(); // root succeeds expect(terminals).toEqual([action, child, root]); expect(world.has(root, Succeeded)).toBe(true); }); it("empty sequential succeeds immediately", () => { const seq = makeSequential(world); runner.schedule(seq); runner.tick(); expect(world.has(seq, Succeeded)).toBe(true); }); }); // ── Parallel ──────────────────────────────────────── describe("Parallel tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("starts all children at once", () => { const par = makeParallel(world); const a = makeWait(world, par); const b = makeWait(world, par); const c = makeWait(world, par); runner.schedule(par); runner.tick(); 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", () => { const par = makeParallel(world); const a = makeWait(world, par); const b = makeWait(world, par); runner.schedule(par); runner.tick(); // schedules both runner.tick(); // runs a runner.succeed(a); // par is re-scheduled, but b is still running runner.tick(); // par sees a done, b still needs scheduling // b should be scheduled (it was removed from Scheduled when ticked) // Actually: b was scheduled in first tick, then ticked in second tick // Let me trace more carefully... // After first tick: a=Scheduled, b=Scheduled, par=no status // Second tick processes Scheduled: a and b both get ticked // a runs, b runs. Both are Running. // We succeed a → par gets Scheduled // Third tick: par sees a=Succeeded, b=Running → waits // We need to succeed b too runner.succeed(b); // par gets Scheduled again runner.tick(); // par sees both done → succeeds expect(world.has(par, Succeeded)).toBe(true); }); it("fails immediately when any child fails", () => { const par = makeParallel(world); const a = makeWait(world, par); const b = makeWait(world, par); runner.schedule(par); runner.tick(); // schedules both runner.tick(); // runs a runner.tick(); // runs b runner.fail(a); // par re-scheduled runner.tick(); // sees a failed → par fails expect(world.has(par, Failed)).toBe(true); }); it("empty parallel succeeds immediately", () => { const par = makeParallel(world); runner.schedule(par); runner.tick(); expect(world.has(par, Succeeded)).toBe(true); }); }); // ── Random ────────────────────────────────────────── describe("Random tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("picks one child and succeeds/fails with it", () => { const rand = makeRandom(world); const a = makeWait(world, rand); const b = makeWait(world, rand); runner.schedule(rand); runner.tick(); // 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, Running) ? a : b; runner.succeed(picked); expect(world.has(rand, Succeeded)).toBe(true); }); it("fails when picked child fails", () => { const rand = makeRandom(world); const a = makeWait(world, rand); runner.schedule(rand); runner.tick(); // schedules a (only child) runner.tick(); // runs a runner.fail(a); runner.tick(); // random fails expect(world.has(rand, Failed)).toBe(true); }); it("empty random does nothing (no children to pick)", () => { const rand = makeRandom(world); runner.schedule(rand); runner.tick(); // No children, so no status change expect(world.has(rand, Succeeded)).toBe(false); expect(world.has(rand, Failed)).toBe(false); }); }); // ── Cycle ────────────────────────────────────────── describe("Cycle tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("re-runs child after it succeeds", () => { const cyc = makeCycle(world); const action = makeWait(world, cyc); let waitCount = 0; runner.onWait = () => waitCount++; runner.schedule(cyc); runner.tick(); expect(waitCount).toBe(1); runner.succeed(action); // Completion schedules the cycle node at a tick boundary. expect(world.has(cyc, Scheduled)).toBe(true); runner.tick(); expect(waitCount).toBe(2); runner.succeed(action); runner.tick(); expect(waitCount).toBe(3); }); it("re-runs child after it fails", () => { const cyc = makeCycle(world); const action = makeWait(world, cyc); let waitCount = 0; runner.onWait = () => waitCount++; runner.schedule(cyc); runner.tick(); runner.fail(action); expect(world.has(cyc, Scheduled)).toBe(true); runner.tick(); expect(waitCount).toBe(2); }); it("never terminates on its own", () => { const cyc = makeCycle(world); const action = makeWait(world, cyc); runner.schedule(cyc); runner.tick(); runner.succeed(action); // After many cycles, cycle is still not terminal for (let i = 0; i < 5; i++) { runner.tick(); runner.succeed(action); } 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 cyc = makeCycle(world); const action = makeWait(world, cyc); runner.schedule(cyc); runner.tick(); runner.cancel(cyc); expect(world.has(cyc, Cancelled)).toBe(true); expect(world.has(action, Cancelled)).toBe(true); }); it("empty cycle does nothing", () => { const cyc = makeCycle(world); runner.schedule(cyc); runner.tick(); // No child, so nothing happens expect(world.has(cyc, Succeeded)).toBe(false); expect(world.has(cyc, Failed)).toBe(false); }); it("cycle inside sequential advances parent when cancelled", () => { const seq = makeSequential(world); const cyc = makeCycle(world, seq); const action = makeWait(world, cyc); const after = makeWait(world, seq); runner.schedule(seq); runner.tick(); // Cancel the cycle runner.cancel(cyc); expect(world.has(seq, Cancelled)).toBe(true); expect(world.has(after, Scheduled)).toBe(false); }); }); // ── Selector ──────────────────────────────────────── describe("Selector tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("succeeds on first child that succeeds", () => { const sel = makeSelector(world); const a = makeWait(world, sel); const b = makeWait(world, sel); runner.schedule(sel); runner.tick(); // schedules a runner.tick(); // runs a runner.succeed(a); // Selector re-scheduled, sees a succeeded runner.tick(); expect(world.has(sel, Succeeded)).toBe(true); // b was never touched expect(world.has(b, Scheduled)).toBe(false); expect(world.has(b, Running)).toBe(false); }); it("tries next child when previous fails", () => { const sel = makeSelector(world); const a = makeWait(world, sel); const b = makeWait(world, sel); const c = makeWait(world, sel); runner.schedule(sel); runner.tick(); // schedules a runner.tick(); // runs a runner.fail(a); expect(world.has(b, Running)).toBe(true); runner.fail(b); expect(world.has(c, Running)).toBe(true); runner.succeed(c); expect(world.has(sel, Succeeded)).toBe(true); }); it("fails when all children fail", () => { const sel = makeSelector(world); const a = makeWait(world, sel); const b = makeWait(world, sel); runner.schedule(sel); runner.tick(); // schedules a runner.tick(); // runs a runner.fail(a); runner.tick(); // schedules b runner.tick(); // runs b runner.fail(b); runner.tick(); // all failed expect(world.has(sel, Failed)).toBe(true); }); it("empty selector fails immediately", () => { const sel = makeSelector(world); runner.schedule(sel); runner.tick(); expect(world.has(sel, Failed)).toBe(true); }); it("skips cancelled children and continues", () => { const sel = makeSelector(world); const a = makeWait(world, sel); const b = makeWait(world, sel); runner.schedule(sel); runner.tick(); // schedules a runner.tick(); // runs a runner.cancel(a); expect(world.has(b, Running)).toBe(true); }); }); // ── Cancel ────────────────────────────────────────── describe("Cancel", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("cancels all descendants", () => { const root = makeSequential(world); const child = makeParallel(world, root); const a = makeWait(world, child); const b = makeWait(world, child); runner.schedule(root); runner.tick(); // schedules child runner.tick(); // schedules a, b runner.tick(); // runs a runner.tick(); // runs b runner.cancel(root); expect(world.has(root, Cancelled)).toBe(true); expect(world.has(child, Cancelled)).toBe(true); expect(world.has(a, Cancelled)).toBe(true); expect(world.has(b, Cancelled)).toBe(true); }); it("cancel propagates to parent", () => { const root = makeSequential(world); const child = makeSequential(world, root); const action = makeWait(world, child); runner.schedule(root); runner.tick(); // schedules child runner.tick(); // schedules action runner.tick(); // runs action runner.cancel(action); // action cancelled → child re-scheduled → child sees action cancelled → child cancelled runner.tick(); // child processes cancelled action expect(world.has(child, Cancelled)).toBe(true); // child cancelled → root re-scheduled → root sees child cancelled → root cancelled runner.tick(); expect(world.has(root, Cancelled)).toBe(true); }); }); // ── Multi-frame wait tasks ────────────────────────────── describe("Multi-frame wait tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("wait task stays Running across ticks until explicitly finished", () => { const action = makeWait(world); runner.schedule(action); runner.tick(); expect(world.has(action, Running)).toBe(true); // Tick again — action is Running, not Scheduled, so nothing happens runner.tick(); expect(world.has(action, Running)).toBe(true); // External system finishes it runner.succeed(action); expect(world.has(action, Succeeded)).toBe(true); expect(world.has(action, Running)).toBe(false); }); it("sequential waits for multi-frame wait before advancing", () => { const seq = makeSequential(world); const a = makeWait(world, seq); const b = makeWait(world, seq); runner.schedule(seq); runner.tick(); // schedules a runner.tick(); // runs a → Running // Tick several times — seq should not advance runner.tick(); runner.tick(); expect(world.has(b, Scheduled)).toBe(false); expect(world.has(b, Running)).toBe(false); // Finish a; parent propagation starts b immediately. runner.succeed(a); expect(world.has(b, Running)).toBe(true); }); }); // ── Edge cases ────────────────────────────────────── describe("Edge cases", () => { it("schedule() is a no-op on non-task entities", () => { const world = new World(); const runner = new TaskRunner(world); const e = world.spawn(); runner.schedule(e); expect(world.has(e, Scheduled)).toBe(false); }); it("succeed/fail/cancel are no-ops on non-task entities", () => { const world = new World(); const runner = new TaskRunner(world); const e = world.spawn(); expect(() => runner.succeed(e)).not.toThrow(); expect(() => runner.fail(e)).not.toThrow(); expect(() => runner.cancel(e)).not.toThrow(); }); it("tick is a no-op when nothing is scheduled", () => { const world = new World(); const runner = new TaskRunner(world); const action = makeWait(world); // Wait task exists but is not Scheduled expect(() => runner.tick()).not.toThrow(); expect(world.has(action, Running)).toBe(false); }); it("deeply nested tree works correctly", () => { const world = new World(); const runner = new TaskRunner(world); const root = makeSequential(world); const mid = makeSequential(world, root); const action = makeWait(world, mid); runner.schedule(root); runner.tick(); expect(world.has(action, Running)).toBe(true); runner.succeed(action); expect(world.has(mid, Succeeded)).toBe(true); expect(world.has(root, Succeeded)).toBe(true); }); });