diff --git a/src/bt/runner.ts b/src/bt/runner.ts index 1b8da00..4d31b52 100644 --- a/src/bt/runner.ts +++ b/src/bt/runner.ts @@ -150,6 +150,12 @@ export class TaskRunner { case "random": this._executeRandom(entity); break; + case "repeat": + this._executeRepeat(entity); + break; + case "selector": + this._executeSelector(entity); + break; } } @@ -244,6 +250,56 @@ export class TaskRunner { // If no eligible children (all running), wait for one to finish } + private _executeRepeat(entity: Entity): void { + const children = childrenOf(this._world, entity); + + // Repeat expects exactly one child + if (children.length === 0) return; + + const child = children[0]; + + // If child reached a terminal, reset it and schedule again + if (isTerminal(this._world, child)) { + clearStatus(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._world.add(child, Scheduled); + } + // Repeat itself never terminates — it just keeps the child going + } + + 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; + } + + // Found a child that hasn't run yet — schedule it + this._world.add(child, Scheduled); + return; + } + + // All children failed + this._finish(entity, Failed); + } + // ── Completion propagation ──────────────────────── private _finish( diff --git a/src/bt/task.ts b/src/bt/task.ts index cf3db0f..1235a12 100644 --- a/src/bt/task.ts +++ b/src/bt/task.ts @@ -13,12 +13,22 @@ import { defineRelationship } from "../relationship"; * Succeeds when all children succeed; fails when any child fails. * - `"random"` — picks one child at random each time it's scheduled. * 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). + * - `"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", + kind: "leaf" as + | "leaf" + | "sequential" + | "parallel" + | "random" + | "repeat" + | "selector", }); -export type TaskKind = typeof Task.type["kind"]; +export type TaskKind = (typeof Task.type)["kind"]; // ── Status tags (zero-size — presence is the signal) ── /** A task that should be executed this tick. */ diff --git a/test/bt.test.ts b/test/bt.test.ts index 409eade..bbf3d8b 100644 --- a/test/bt.test.ts +++ b/test/bt.test.ts @@ -40,6 +40,20 @@ function makeRandom(world: World, parent?: Entity): Entity { return e; } +function makeRepeat(world: World, parent?: Entity): Entity { + const e = world.spawn(); + world.add(e, Task, { kind: "repeat" }); + 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; +} + // ── Leaf tasks ────────────────────────────────────── describe("Leaf tasks", () => { let world: World; @@ -372,6 +386,223 @@ describe("Random tasks", () => { }); }); +// ── Repeat ────────────────────────────────────────── +describe("Repeat tasks", () => { + let world: World; + let runner: TaskRunner; + + beforeEach(() => { + world = new World(); + runner = new TaskRunner(world); + }); + + it("re-runs child after it succeeds", () => { + const rep = makeRepeat(world); + const leaf = makeLeaf(world, rep); + + let leafCount = 0; + runner.onLeaf = () => leafCount++; + + runner.schedule(rep); + + // First run + runner.tick(); // repeat schedules leaf + runner.tick(); // leaf runs + 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 + expect(leafCount).toBe(2); + runner.succeed(leaf); + + // And again + runner.tick(); // repeat resets + runner.tick(); // leaf runs + expect(leafCount).toBe(3); + }); + + it("re-runs child after it fails", () => { + const rep = makeRepeat(world); + const leaf = makeLeaf(world, rep); + + let leafCount = 0; + runner.onLeaf = () => leafCount++; + + runner.schedule(rep); + runner.tick(); // repeat schedules leaf + runner.tick(); // leaf runs + runner.fail(leaf); + + // Repeat re-scheduled, resets leaf + runner.tick(); // repeat resets leaf + runner.tick(); // leaf runs again + expect(leafCount).toBe(2); + }); + + it("never terminates on its own", () => { + const rep = makeRepeat(world); + const leaf = makeLeaf(world, rep); + + runner.schedule(rep); + runner.tick(); // schedules leaf + runner.tick(); // runs leaf + runner.succeed(leaf); + + // After many cycles, repeat is still not terminal + for (let i = 0; i < 5; i++) { + runner.tick(); // repeat resets + schedules leaf + runner.tick(); // leaf runs + runner.succeed(leaf); + } + + expect(world.has(rep, Succeeded)).toBe(false); + expect(world.has(rep, Failed)).toBe(false); + expect(world.has(rep, Cancelled)).toBe(false); + }); + + it("can be cancelled", () => { + const rep = makeRepeat(world); + const leaf = makeLeaf(world, rep); + + runner.schedule(rep); + runner.tick(); // schedules leaf + runner.tick(); // runs leaf + + runner.cancel(rep); + + expect(world.has(rep, Cancelled)).toBe(true); + expect(world.has(leaf, Cancelled)).toBe(true); + }); + + it("empty repeat does nothing", () => { + const rep = makeRepeat(world); + + runner.schedule(rep); + runner.tick(); + + // No child, so nothing happens + expect(world.has(rep, Succeeded)).toBe(false); + expect(world.has(rep, Failed)).toBe(false); + }); + + it("repeat inside sequential advances parent when cancelled", () => { + const seq = makeSequential(world); + const rep = makeRepeat(world, seq); + const leaf = makeLeaf(world, rep); + const after = makeLeaf(world, seq); + + runner.schedule(seq); + runner.tick(); // seq schedules rep + runner.tick(); // rep schedules leaf + runner.tick(); // leaf runs + + // Cancel the repeat + runner.cancel(rep); + runner.tick(); // seq sees rep cancelled, seq becomes cancelled + + 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 = makeLeaf(world, sel); + const b = makeLeaf(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 = makeLeaf(world, sel); + const b = makeLeaf(world, sel); + const c = makeLeaf(world, sel); + + runner.schedule(sel); + runner.tick(); // schedules a + runner.tick(); // runs a + runner.fail(a); + + runner.tick(); // selector schedules b + expect(world.has(b, Scheduled)).toBe(true); + + runner.tick(); // runs b + runner.fail(b); + + 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); + }); + + it("fails when all children fail", () => { + const sel = makeSelector(world); + const a = makeLeaf(world, sel); + const b = makeLeaf(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 = makeLeaf(world, sel); + const b = makeLeaf(world, sel); + + runner.schedule(sel); + runner.tick(); // schedules a + runner.tick(); // runs a + runner.cancel(a); + + runner.tick(); // selector sees a cancelled, tries b + expect(world.has(b, Scheduled)).toBe(true); + }); +}); + // ── Cancel ────────────────────────────────────────── describe("Cancel", () => { let world: World;