feat(bt): add repeat and selector task kinds

This commit is contained in:
2026-06-01 22:57:14 +08:00
parent 4e37e03d3f
commit 3620e80807
3 changed files with 299 additions and 2 deletions
+231
View File
@@ -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;