feat(bt): add repeat and selector task kinds

This commit is contained in:
hypercross 2026-06-01 22:57:14 +08:00
parent 4e37e03d3f
commit 3620e80807
3 changed files with 299 additions and 2 deletions

View File

@ -150,6 +150,12 @@ export class TaskRunner {
case "random": case "random":
this._executeRandom(entity); this._executeRandom(entity);
break; 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 // 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 ──────────────────────── // ── Completion propagation ────────────────────────
private _finish( private _finish(

View File

@ -13,12 +13,22 @@ import { defineRelationship } from "../relationship";
* 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's scheduled.
* 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
* 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", { 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) ── // ── Status tags (zero-size — presence is the signal) ──
/** A task that should be executed this tick. */ /** A task that should be executed this tick. */

View File

@ -40,6 +40,20 @@ function makeRandom(world: World, parent?: Entity): Entity {
return e; 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 ────────────────────────────────────── // ── Leaf tasks ──────────────────────────────────────
describe("Leaf tasks", () => { describe("Leaf tasks", () => {
let world: World; 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 ────────────────────────────────────────── // ── Cancel ──────────────────────────────────────────
describe("Cancel", () => { describe("Cancel", () => {
let world: World; let world: World;