Compare commits
No commits in common. "4e37e03d3fb063716b09870cf8c114ec84283f3e" and "fd78e9ce6d68916d62227a0c035caf60817c5585" have entirely different histories.
4e37e03d3f
...
fd78e9ce6d
10
package.json
10
package.json
|
|
@ -11,16 +11,6 @@
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"require": "./dist/index.cjs"
|
"require": "./dist/index.cjs"
|
||||||
},
|
|
||||||
"./commands": {
|
|
||||||
"types": "./dist/commands/index.d.ts",
|
|
||||||
"import": "./dist/commands/index.js",
|
|
||||||
"require": "./dist/commands/index.cjs"
|
|
||||||
},
|
|
||||||
"./bt": {
|
|
||||||
"types": "./dist/bt/index.d.ts",
|
|
||||||
"import": "./dist/bt/index.js",
|
|
||||||
"require": "./dist/bt/index.cjs"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
export {
|
|
||||||
Task,
|
|
||||||
Scheduled,
|
|
||||||
Running,
|
|
||||||
Succeeded,
|
|
||||||
Failed,
|
|
||||||
Cancelled,
|
|
||||||
TERMINAL_TAGS,
|
|
||||||
ChildOf,
|
|
||||||
} from "./task";
|
|
||||||
export type { TaskKind } from "./task";
|
|
||||||
|
|
||||||
export { TaskRunner } from "./runner";
|
|
||||||
export type { LeafHandler, TerminalHandler } from "./runner";
|
|
||||||
287
src/bt/runner.ts
287
src/bt/runner.ts
|
|
@ -1,287 +0,0 @@
|
||||||
import type { World, Entity } from "../index";
|
|
||||||
import { query } from "../query";
|
|
||||||
import {
|
|
||||||
Task,
|
|
||||||
Scheduled,
|
|
||||||
Running,
|
|
||||||
Succeeded,
|
|
||||||
Failed,
|
|
||||||
Cancelled,
|
|
||||||
TERMINAL_TAGS,
|
|
||||||
ChildOf,
|
|
||||||
} from "./task";
|
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────
|
|
||||||
/** Callback invoked for each leaf task that becomes Scheduled. */
|
|
||||||
export type LeafHandler = (world: World, entity: Entity) => void;
|
|
||||||
|
|
||||||
/** Callback invoked when a task reaches a terminal status. */
|
|
||||||
export type TerminalHandler = (
|
|
||||||
world: World,
|
|
||||||
entity: Entity,
|
|
||||||
status: "succeeded" | "failed" | "cancelled",
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────
|
|
||||||
function terminalStatus(
|
|
||||||
world: World,
|
|
||||||
entity: Entity,
|
|
||||||
): "succeeded" | "failed" | "cancelled" | null {
|
|
||||||
if (world.has(entity, Succeeded)) return "succeeded";
|
|
||||||
if (world.has(entity, Failed)) return "failed";
|
|
||||||
if (world.has(entity, Cancelled)) return "cancelled";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTerminal(world: World, entity: Entity): boolean {
|
|
||||||
return terminalStatus(world, entity) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearStatus(world: World, entity: Entity): void {
|
|
||||||
for (const tag of TERMINAL_TAGS) {
|
|
||||||
if (world.has(entity, tag)) world.remove(entity, tag);
|
|
||||||
}
|
|
||||||
if (world.has(entity, Running)) world.remove(entity, Running);
|
|
||||||
}
|
|
||||||
|
|
||||||
function childrenOf(world: World, parent: Entity): Entity[] {
|
|
||||||
return world.getRelatedTo(parent, ChildOf);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentOf(world: World, child: Entity): Entity | null {
|
|
||||||
return world.getRelated(child, ChildOf) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TaskRunner ────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* Leaves are dispatched to a user-provided `onLeaf` callback.
|
|
||||||
* Terminal results are dispatched to a user-provided `onTerminal` callback.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const runner = new TaskRunner(world);
|
|
||||||
* runner.onLeaf = (world, entity) => {
|
|
||||||
* // do the leaf's work, then call:
|
|
||||||
* runner.succeed(entity);
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* // Each frame:
|
|
||||||
* runner.tick();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class TaskRunner {
|
|
||||||
private _world: World;
|
|
||||||
|
|
||||||
/** Called when a leaf task becomes Scheduled. */
|
|
||||||
onLeaf: LeafHandler = () => {};
|
|
||||||
|
|
||||||
/** Called when any task reaches a terminal status. */
|
|
||||||
onTerminal: TerminalHandler = () => {};
|
|
||||||
|
|
||||||
constructor(world: World) {
|
|
||||||
this._world = world;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process all Scheduled tasks.
|
|
||||||
*
|
|
||||||
* Call once per frame. Only entities with `Scheduled` are touched.
|
|
||||||
*/
|
|
||||||
tick(): void {
|
|
||||||
const scheduled = [...this._world.query(query(Task, Scheduled))];
|
|
||||||
for (const entity of scheduled) {
|
|
||||||
this._world.remove(entity, Scheduled);
|
|
||||||
this._execute(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark a leaf task as succeeded and propagate upward. */
|
|
||||||
succeed(entity: Entity): void {
|
|
||||||
this._finish(entity, Succeeded);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark a leaf task as failed and propagate upward. */
|
|
||||||
fail(entity: Entity): void {
|
|
||||||
this._finish(entity, Failed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cancel a task and all its descendants. */
|
|
||||||
cancel(entity: Entity): void {
|
|
||||||
this._cancelTree(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Schedule a task for execution next tick. */
|
|
||||||
schedule(entity: Entity): void {
|
|
||||||
if (this._world.has(entity, Task)) {
|
|
||||||
this._world.add(entity, Scheduled);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset a task to idle (removes all status tags). */
|
|
||||||
reset(entity: Entity): void {
|
|
||||||
clearStatus(this._world, entity);
|
|
||||||
this._world.remove(entity, Scheduled);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal execution ────────────────────────────
|
|
||||||
|
|
||||||
private _execute(entity: Entity): void {
|
|
||||||
const t = this._world.get(entity, Task);
|
|
||||||
|
|
||||||
switch (t.kind) {
|
|
||||||
case "leaf":
|
|
||||||
this._executeLeaf(entity);
|
|
||||||
break;
|
|
||||||
case "sequential":
|
|
||||||
this._executeSequential(entity);
|
|
||||||
break;
|
|
||||||
case "parallel":
|
|
||||||
this._executeParallel(entity);
|
|
||||||
break;
|
|
||||||
case "random":
|
|
||||||
this._executeRandom(entity);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _executeLeaf(entity: Entity): void {
|
|
||||||
this._world.add(entity, Running);
|
|
||||||
this.onLeaf(this._world, entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
this._finish(entity, Succeeded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _executeRandom(entity: Entity): void {
|
|
||||||
const children = childrenOf(this._world, entity);
|
|
||||||
|
|
||||||
// Check if any child already reached a terminal
|
|
||||||
for (const child of children) {
|
|
||||||
if (isTerminal(this._world, child)) {
|
|
||||||
const status = terminalStatus(this._world, child)!;
|
|
||||||
this._finish(
|
|
||||||
entity,
|
|
||||||
status === "succeeded"
|
|
||||||
? Succeeded
|
|
||||||
: status === "cancelled"
|
|
||||||
? Cancelled
|
|
||||||
: Failed,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pick a random child that isn't already running
|
|
||||||
const eligible = children.filter(
|
|
||||||
(c) => !this._world.has(c, Running) && !this._world.has(c, Scheduled),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (eligible.length > 0) {
|
|
||||||
const pick = eligible[Math.floor(Math.random() * eligible.length)];
|
|
||||||
this._world.add(pick, Scheduled);
|
|
||||||
}
|
|
||||||
// If no eligible children (all running), wait for one to finish
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Completion propagation ────────────────────────
|
|
||||||
|
|
||||||
private _finish(
|
|
||||||
entity: Entity,
|
|
||||||
tag: typeof Succeeded | typeof Failed | typeof Cancelled,
|
|
||||||
): void {
|
|
||||||
if (!this._world.has(entity, Task)) return;
|
|
||||||
|
|
||||||
clearStatus(this._world, entity);
|
|
||||||
this._world.add(entity, tag);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _cancelTree(entity: Entity): void {
|
|
||||||
if (!this._world.has(entity, Task)) return;
|
|
||||||
|
|
||||||
// Cancel children first
|
|
||||||
for (const child of childrenOf(this._world, entity)) {
|
|
||||||
this._cancelTree(child);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { defineComponent } from "../component";
|
|
||||||
import { defineRelationship } from "../relationship";
|
|
||||||
|
|
||||||
// ── Task component ────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Core component for behaviour-tree tasks.
|
|
||||||
*
|
|
||||||
* `kind` determines how the task evaluates its children:
|
|
||||||
* - `"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.
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export const Task = defineComponent("task", {
|
|
||||||
kind: "leaf" as "leaf" | "sequential" | "parallel" | "random",
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TaskKind = typeof Task.type["kind"];
|
|
||||||
|
|
||||||
// ── Status tags (zero-size — presence is the signal) ──
|
|
||||||
/** A task that should be executed this tick. */
|
|
||||||
export const Scheduled = defineComponent("scheduled", {});
|
|
||||||
|
|
||||||
/** A task that is currently executing (multi-frame leaves). */
|
|
||||||
export const Running = defineComponent("running", {});
|
|
||||||
|
|
||||||
/** The task completed successfully. */
|
|
||||||
export const Succeeded = defineComponent("succeeded", {});
|
|
||||||
|
|
||||||
/** The task failed. */
|
|
||||||
export const Failed = defineComponent("failed", {});
|
|
||||||
|
|
||||||
/** The task was cancelled externally. */
|
|
||||||
export const Cancelled = defineComponent("cancelled", {});
|
|
||||||
|
|
||||||
/** All terminal status tags. */
|
|
||||||
export const TERMINAL_TAGS = [Succeeded, Failed, Cancelled] as const;
|
|
||||||
|
|
||||||
// ── Relationship ──────────────────────────────────────
|
|
||||||
/** Parent → child edge in the task tree. */
|
|
||||||
export const ChildOf = defineRelationship("taskChild");
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { query as makeQuery } from "../query";
|
|
||||||
import type { World, Entity, ComponentDef, Query } from "../index";
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────
|
|
||||||
/** A handler that processes a command extracted from an entity. */
|
|
||||||
export type CommandHandler<T extends Record<string, any>> = (
|
|
||||||
command: T,
|
|
||||||
entity?: Entity,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
/** Pending work: entity, its command data, and the handler to invoke. */
|
|
||||||
interface Pending<T extends Record<string, any> = any> {
|
|
||||||
entity: Entity;
|
|
||||||
handler: CommandHandler<T>;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Registered handler bookkeeping. */
|
|
||||||
interface Registration<T extends Record<string, any> = any> {
|
|
||||||
def: ComponentDef<T>;
|
|
||||||
query: Query;
|
|
||||||
handler: CommandHandler<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CommandQueue ─────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Pull-based command system.
|
|
||||||
*
|
|
||||||
* Register handlers for command component types, then call `execute()` each
|
|
||||||
* frame. It scans the world for entities carrying command components, removes
|
|
||||||
* them, dispatches to handlers, and destroys entities that become empty.
|
|
||||||
*
|
|
||||||
* Interruptions pause processing — while any tracked promise is unresolved,
|
|
||||||
* `execute()` is a no-op.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const Damage = defineComponent('damage', { amount: 0 });
|
|
||||||
*
|
|
||||||
* const queue = new CommandQueue(world);
|
|
||||||
* queue.handle(Damage, (cmd, entity) => {
|
|
||||||
* const hp = world.get(entity!, Health);
|
|
||||||
* hp.current -= cmd.amount;
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // each frame:
|
|
||||||
* queue.execute();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class CommandQueue {
|
|
||||||
private _world: World;
|
|
||||||
private _registrations: Registration[] = [];
|
|
||||||
private _pendingPromises = new Set<Promise<any>>();
|
|
||||||
private _interrupted = false;
|
|
||||||
|
|
||||||
constructor(world: World) {
|
|
||||||
this._world = world;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Registration ─────────────────────────────────
|
|
||||||
|
|
||||||
/** Register a handler for `def`. Each handler is called once per entity per frame. */
|
|
||||||
handle<T extends Record<string, any>>(
|
|
||||||
def: ComponentDef<T>,
|
|
||||||
handler: CommandHandler<T>,
|
|
||||||
): this {
|
|
||||||
this._registrations.push({ def, query: makeQuery(def), handler });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Interruption ─────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track a promise. While any tracked promise is unresolved,
|
|
||||||
* `execute()` skips command processing.
|
|
||||||
*
|
|
||||||
* Once all tracked promises have settled, processing resumes.
|
|
||||||
*/
|
|
||||||
interrupt(promise: Promise<any>): void {
|
|
||||||
this._pendingPromises.add(promise);
|
|
||||||
this._interrupted = true;
|
|
||||||
|
|
||||||
const remove = () => {
|
|
||||||
this._pendingPromises.delete(promise);
|
|
||||||
if (this._pendingPromises.size === 0) {
|
|
||||||
this._interrupted = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
promise.then(remove, remove);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True while at least one interruption promise is pending. */
|
|
||||||
get isInterrupted(): boolean {
|
|
||||||
return this._interrupted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Execution ─────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drain all command components from the world and dispatch to handlers.
|
|
||||||
*
|
|
||||||
* For each registered component type, every matching entity has the
|
|
||||||
* component removed. The handler receives the entity and the command
|
|
||||||
* data. If the entity has no components left after removal, it is
|
|
||||||
* destroyed.
|
|
||||||
*
|
|
||||||
* If `isInterrupted` is true, this method is a no-op.
|
|
||||||
*/
|
|
||||||
execute(): void {
|
|
||||||
if (this._interrupted) return;
|
|
||||||
|
|
||||||
const pending: Pending[] = [];
|
|
||||||
|
|
||||||
// 1. Snapshot + remove command components
|
|
||||||
for (const reg of this._registrations) {
|
|
||||||
// Snapshot into array; sparse-set iteration is not mutation-safe
|
|
||||||
const entities = [...this._world.query(reg.query)];
|
|
||||||
for (const entity of entities) {
|
|
||||||
const data = this._world.tryGet(entity, reg.def);
|
|
||||||
if (data !== undefined) {
|
|
||||||
this._world.remove(entity, reg.def);
|
|
||||||
pending.push({ entity, handler: reg.handler, data });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Destroy entities that became empty after command removal
|
|
||||||
for (const p of pending) {
|
|
||||||
if (
|
|
||||||
this._world.isAlive(p.entity) &&
|
|
||||||
!this._world.hasAnyComponent(p.entity)
|
|
||||||
) {
|
|
||||||
this._world.destroy(p.entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Dispatch handlers (after cleanup so handlers see consistent state)
|
|
||||||
for (const p of pending) {
|
|
||||||
p.handler(p.data, p.entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { CommandQueue } from "./command-queue";
|
|
||||||
export type { CommandHandler } from "./command-queue";
|
|
||||||
33
src/world.ts
33
src/world.ts
|
|
@ -21,8 +21,6 @@ export class World {
|
||||||
// ── Entity pools ──────────────────────────────────
|
// ── Entity pools ──────────────────────────────────
|
||||||
private _generations: number[] = [];
|
private _generations: number[] = [];
|
||||||
private _free: number[] = [];
|
private _free: number[] = [];
|
||||||
private _componentCounts: number[] = [];
|
|
||||||
private _relCounts: number[] = [];
|
|
||||||
|
|
||||||
// ── Component storage ─────────────────────────────
|
// ── Component storage ─────────────────────────────
|
||||||
private _components = new Map<symbol, SparseSet<any>>();
|
private _components = new Map<symbol, SparseSet<any>>();
|
||||||
|
|
@ -54,8 +52,6 @@ export class World {
|
||||||
if (this._free.length > 0) {
|
if (this._free.length > 0) {
|
||||||
const idx = this._free.pop()!;
|
const idx = this._free.pop()!;
|
||||||
const gen = this._generations[idx];
|
const gen = this._generations[idx];
|
||||||
this._componentCounts[idx] = 0;
|
|
||||||
this._relCounts[idx] = 0;
|
|
||||||
const e = makeEntity(idx, gen);
|
const e = makeEntity(idx, gen);
|
||||||
this._emit({ type: "spawned", entity: e });
|
this._emit({ type: "spawned", entity: e });
|
||||||
return e;
|
return e;
|
||||||
|
|
@ -63,8 +59,6 @@ export class World {
|
||||||
|
|
||||||
const idx = this._generations.length;
|
const idx = this._generations.length;
|
||||||
this._generations.push(1);
|
this._generations.push(1);
|
||||||
this._componentCounts.push(0);
|
|
||||||
this._relCounts.push(0);
|
|
||||||
const e = makeEntity(idx, 1);
|
const e = makeEntity(idx, 1);
|
||||||
this._emit({ type: "spawned", entity: e });
|
this._emit({ type: "spawned", entity: e });
|
||||||
return e;
|
return e;
|
||||||
|
|
@ -75,14 +69,6 @@ export class World {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
if (!this._isAlive(idx, entity)) return;
|
if (!this._isAlive(idx, entity)) return;
|
||||||
|
|
||||||
// Short-circuit: truly bare entities have nothing to clean up
|
|
||||||
if (this._componentCounts[idx] === 0 && this._relCounts[idx] === 0) {
|
|
||||||
this._generations[idx]++;
|
|
||||||
this._free.push(idx);
|
|
||||||
this._emit({ type: "destroyed", entity });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean relationships before components
|
// Clean relationships before components
|
||||||
for (const [key] of this._relForward) {
|
for (const [key] of this._relForward) {
|
||||||
// Entity as source
|
// Entity as source
|
||||||
|
|
@ -113,8 +99,6 @@ export class World {
|
||||||
dirty.delete(idx);
|
dirty.delete(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._componentCounts[idx] = 0;
|
|
||||||
this._relCounts[idx] = 0;
|
|
||||||
this._generations[idx]++;
|
this._generations[idx]++;
|
||||||
this._free.push(idx);
|
this._free.push(idx);
|
||||||
|
|
||||||
|
|
@ -126,13 +110,6 @@ export class World {
|
||||||
return this._isAlive(entityIndex(entity), entity);
|
return this._isAlive(entityIndex(entity), entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if the entity holds at least one component of any type. */
|
|
||||||
hasAnyComponent(entity: Entity): boolean {
|
|
||||||
const idx = entityIndex(entity);
|
|
||||||
if (!this._isAlive(idx, entity)) return false;
|
|
||||||
return this._componentCounts[idx] > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component operations ──────────────────────────
|
// ── Component operations ──────────────────────────
|
||||||
|
|
||||||
add<T extends Record<string, any>>(
|
add<T extends Record<string, any>>(
|
||||||
|
|
@ -144,12 +121,9 @@ export class World {
|
||||||
this._assertAlive(idx, entity);
|
this._assertAlive(idx, entity);
|
||||||
|
|
||||||
const store = this._getOrCreateStore(def);
|
const store = this._getOrCreateStore(def);
|
||||||
const existed = store.has(idx);
|
|
||||||
const value = { ...def.defaults, ...init };
|
const value = { ...def.defaults, ...init };
|
||||||
store.set(idx, value);
|
store.set(idx, value);
|
||||||
|
|
||||||
if (!existed) this._componentCounts[idx]++;
|
|
||||||
|
|
||||||
this._emit({ type: "componentAdded", entity, component: def });
|
this._emit({ type: "componentAdded", entity, component: def });
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +139,6 @@ export class World {
|
||||||
this._dirty.get(def._key)?.delete(idx);
|
this._dirty.get(def._key)?.delete(idx);
|
||||||
|
|
||||||
if (removed) {
|
if (removed) {
|
||||||
this._componentCounts[idx]--;
|
|
||||||
this._emit({ type: "componentRemoved", entity, component: def });
|
this._emit({ type: "componentRemoved", entity, component: def });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -282,9 +255,6 @@ export class World {
|
||||||
}
|
}
|
||||||
rev.add(si);
|
rev.add(si);
|
||||||
|
|
||||||
this._relCounts[si]++;
|
|
||||||
this._relCounts[ti]++;
|
|
||||||
|
|
||||||
this._emit({
|
this._emit({
|
||||||
type: "relationshipAdded",
|
type: "relationshipAdded",
|
||||||
source,
|
source,
|
||||||
|
|
@ -590,9 +560,6 @@ export class World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._relCounts[si]--;
|
|
||||||
this._relCounts[ti]--;
|
|
||||||
|
|
||||||
this._emit({
|
this._emit({
|
||||||
type: "relationshipRemoved",
|
type: "relationshipRemoved",
|
||||||
source,
|
source,
|
||||||
|
|
|
||||||
539
test/bt.test.ts
539
test/bt.test.ts
|
|
@ -1,539 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import { World, type Entity } from "../src/index";
|
|
||||||
import {
|
|
||||||
Task,
|
|
||||||
Scheduled,
|
|
||||||
Running,
|
|
||||||
Succeeded,
|
|
||||||
Failed,
|
|
||||||
Cancelled,
|
|
||||||
ChildOf,
|
|
||||||
TaskRunner,
|
|
||||||
} from "../src/bt/index";
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────
|
|
||||||
function makeLeaf(world: World, parent?: Entity): Entity {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Task, { kind: "leaf" });
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Leaf tasks ──────────────────────────────────────
|
|
||||||
describe("Leaf tasks", () => {
|
|
||||||
let world: World;
|
|
||||||
let runner: TaskRunner;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
runner = new TaskRunner(world);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onLeaf when a leaf is scheduled and ticked", () => {
|
|
||||||
const leaf = makeLeaf(world);
|
|
||||||
const calls: Entity[] = [];
|
|
||||||
|
|
||||||
runner.onLeaf = (_w, e) => calls.push(e);
|
|
||||||
runner.schedule(leaf);
|
|
||||||
runner.tick();
|
|
||||||
|
|
||||||
expect(calls).toEqual([leaf]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks leaf as Running after tick", () => {
|
|
||||||
const leaf = makeLeaf(world);
|
|
||||||
runner.schedule(leaf);
|
|
||||||
runner.tick();
|
|
||||||
|
|
||||||
expect(world.has(leaf, Running)).toBe(true);
|
|
||||||
expect(world.has(leaf, Scheduled)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("succeed() marks leaf as Succeeded", () => {
|
|
||||||
const leaf = makeLeaf(world);
|
|
||||||
runner.schedule(leaf);
|
|
||||||
runner.tick();
|
|
||||||
runner.succeed(leaf);
|
|
||||||
|
|
||||||
expect(world.has(leaf, Succeeded)).toBe(true);
|
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fail() marks leaf as Failed", () => {
|
|
||||||
const leaf = makeLeaf(world);
|
|
||||||
runner.schedule(leaf);
|
|
||||||
runner.tick();
|
|
||||||
runner.fail(leaf);
|
|
||||||
|
|
||||||
expect(world.has(leaf, Failed)).toBe(true);
|
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancel() marks leaf as Cancelled", () => {
|
|
||||||
const leaf = makeLeaf(world);
|
|
||||||
runner.schedule(leaf);
|
|
||||||
runner.tick();
|
|
||||||
runner.cancel(leaf);
|
|
||||||
|
|
||||||
expect(world.has(leaf, Cancelled)).toBe(true);
|
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("onTerminal is called when leaf finishes", () => {
|
|
||||||
const leaf = makeLeaf(world);
|
|
||||||
const terminals: { entity: Entity; status: string }[] = [];
|
|
||||||
|
|
||||||
runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s });
|
|
||||||
runner.schedule(leaf);
|
|
||||||
runner.tick();
|
|
||||||
runner.succeed(leaf);
|
|
||||||
|
|
||||||
expect(terminals).toEqual([{ entity: leaf, status: "succeeded" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reset() clears all status tags", () => {
|
|
||||||
const leaf = makeLeaf(world);
|
|
||||||
runner.schedule(leaf);
|
|
||||||
runner.tick();
|
|
||||||
runner.succeed(leaf);
|
|
||||||
|
|
||||||
runner.reset(leaf);
|
|
||||||
|
|
||||||
expect(world.has(leaf, Succeeded)).toBe(false);
|
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
|
||||||
expect(world.has(leaf, 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 = makeLeaf(world, seq);
|
|
||||||
const b = makeLeaf(world, seq);
|
|
||||||
const c = makeLeaf(world, seq);
|
|
||||||
|
|
||||||
const leafCalls: Entity[] = [];
|
|
||||||
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(seq, Succeeded)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails immediately when a child fails", () => {
|
|
||||||
const seq = makeSequential(world);
|
|
||||||
const a = makeLeaf(world, seq);
|
|
||||||
const b = makeLeaf(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 = makeLeaf(world, seq);
|
|
||||||
const b = makeLeaf(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 leaf = makeLeaf(world, child);
|
|
||||||
|
|
||||||
const terminals: Entity[] = [];
|
|
||||||
runner.onTerminal = (_w, e) => terminals.push(e);
|
|
||||||
|
|
||||||
runner.schedule(root);
|
|
||||||
runner.tick(); // schedules child
|
|
||||||
runner.tick(); // schedules leaf
|
|
||||||
runner.tick(); // runs leaf
|
|
||||||
runner.succeed(leaf);
|
|
||||||
runner.tick(); // child succeeds
|
|
||||||
runner.tick(); // root succeeds
|
|
||||||
|
|
||||||
expect(terminals).toEqual([leaf, 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("schedules all children at once", () => {
|
|
||||||
const par = makeParallel(world);
|
|
||||||
const a = makeLeaf(world, par);
|
|
||||||
const b = makeLeaf(world, par);
|
|
||||||
const c = makeLeaf(world, par);
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("succeeds when all children succeed", () => {
|
|
||||||
const par = makeParallel(world);
|
|
||||||
const a = makeLeaf(world, par);
|
|
||||||
const b = makeLeaf(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 = makeLeaf(world, par);
|
|
||||||
const b = makeLeaf(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 = makeLeaf(world, rand);
|
|
||||||
const b = makeLeaf(world, rand);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const picked = world.has(a, Scheduled) ? a : b;
|
|
||||||
runner.tick(); // runs picked leaf
|
|
||||||
runner.succeed(picked);
|
|
||||||
|
|
||||||
runner.tick(); // random sees child done → succeeds
|
|
||||||
expect(world.has(rand, Succeeded)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails when picked child fails", () => {
|
|
||||||
const rand = makeRandom(world);
|
|
||||||
const a = makeLeaf(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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 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 = makeLeaf(world, child);
|
|
||||||
const b = makeLeaf(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 leaf = makeLeaf(world, child);
|
|
||||||
|
|
||||||
runner.schedule(root);
|
|
||||||
runner.tick(); // schedules child
|
|
||||||
runner.tick(); // schedules leaf
|
|
||||||
runner.tick(); // runs leaf
|
|
||||||
|
|
||||||
runner.cancel(leaf);
|
|
||||||
|
|
||||||
// leaf cancelled → child re-scheduled → child sees leaf cancelled → child cancelled
|
|
||||||
runner.tick(); // child processes cancelled leaf
|
|
||||||
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 leaves ──────────────────────────────
|
|
||||||
describe("Multi-frame leaves", () => {
|
|
||||||
let world: World;
|
|
||||||
let runner: TaskRunner;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
runner = new TaskRunner(world);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaf stays Running across ticks until explicitly finished", () => {
|
|
||||||
const leaf = makeLeaf(world);
|
|
||||||
|
|
||||||
runner.schedule(leaf);
|
|
||||||
runner.tick();
|
|
||||||
|
|
||||||
expect(world.has(leaf, Running)).toBe(true);
|
|
||||||
|
|
||||||
// Tick again — leaf is Running, not Scheduled, so nothing happens
|
|
||||||
runner.tick();
|
|
||||||
expect(world.has(leaf, Running)).toBe(true);
|
|
||||||
|
|
||||||
// External system finishes it
|
|
||||||
runner.succeed(leaf);
|
|
||||||
expect(world.has(leaf, Succeeded)).toBe(true);
|
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sequential waits for multi-frame leaf before advancing", () => {
|
|
||||||
const seq = makeSequential(world);
|
|
||||||
const a = makeLeaf(world, seq);
|
|
||||||
const b = makeLeaf(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
|
|
||||||
runner.succeed(a);
|
|
||||||
runner.tick(); // seq schedules b
|
|
||||||
expect(world.has(b, Scheduled)).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 leaf = makeLeaf(world);
|
|
||||||
|
|
||||||
// Leaf exists but is not Scheduled
|
|
||||||
expect(() => runner.tick()).not.toThrow();
|
|
||||||
expect(world.has(leaf, 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 leaf = makeLeaf(world, mid);
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,284 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import { World, defineComponent, type Entity } from "../src/index";
|
|
||||||
import { CommandQueue } from "../src/commands/command-queue";
|
|
||||||
|
|
||||||
// ── Components ──────────────────────────────────────
|
|
||||||
const Health = defineComponent("health", { current: 100, max: 100 });
|
|
||||||
const DamageCmd = defineComponent("damageCmd", { amount: 0 });
|
|
||||||
const HealCmd = defineComponent("healCmd", { amount: 0 });
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────
|
|
||||||
async function settled() {
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Basic command dispatch ──────────────────────────
|
|
||||||
describe("CommandQueue", () => {
|
|
||||||
let world: World;
|
|
||||||
let queue: CommandQueue;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
queue = new CommandQueue(world);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dispatches commands to registered handlers", () => {
|
|
||||||
const results: { entity: Entity; amount: number }[] = [];
|
|
||||||
|
|
||||||
queue.handle(DamageCmd, (cmd, entity) => {
|
|
||||||
results.push({ entity: entity!, amount: cmd.amount });
|
|
||||||
});
|
|
||||||
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, DamageCmd, { amount: 25 });
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(results).toHaveLength(1);
|
|
||||||
expect(results[0].amount).toBe(25);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes the command component after dispatch", () => {
|
|
||||||
queue.handle(DamageCmd, () => {});
|
|
||||||
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, DamageCmd, { amount: 10 });
|
|
||||||
|
|
||||||
expect(world.has(e, DamageCmd)).toBe(true);
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(world.has(e, DamageCmd)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("destroys an entity when it becomes empty after command removal", () => {
|
|
||||||
queue.handle(DamageCmd, () => {});
|
|
||||||
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, DamageCmd, { amount: 10 });
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(world.isAlive(e)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT destroy entities that still have other components", () => {
|
|
||||||
queue.handle(DamageCmd, () => {});
|
|
||||||
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Health);
|
|
||||||
world.add(e, DamageCmd, { amount: 10 });
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(world.isAlive(e)).toBe(true);
|
|
||||||
expect(world.has(e, Health)).toBe(true);
|
|
||||||
expect(world.has(e, DamageCmd)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles multiple entities with the same command type", () => {
|
|
||||||
const hits: Entity[] = [];
|
|
||||||
queue.handle(DamageCmd, (_, entity) => {
|
|
||||||
hits.push(entity!);
|
|
||||||
});
|
|
||||||
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
const c = world.spawn();
|
|
||||||
world.add(a, DamageCmd, { amount: 5 });
|
|
||||||
world.add(b, DamageCmd, { amount: 10 });
|
|
||||||
world.add(c, DamageCmd, { amount: 15 });
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(hits).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles multiple registered command types", () => {
|
|
||||||
const damages: number[] = [];
|
|
||||||
const heals: number[] = [];
|
|
||||||
|
|
||||||
queue
|
|
||||||
.handle(DamageCmd, (cmd) => damages.push(cmd.amount))
|
|
||||||
.handle(HealCmd, (cmd) => heals.push(cmd.amount));
|
|
||||||
|
|
||||||
const a = world.spawn();
|
|
||||||
const b = world.spawn();
|
|
||||||
world.add(a, DamageCmd, { amount: 5 });
|
|
||||||
world.add(b, HealCmd, { amount: 20 });
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(damages).toEqual([5]);
|
|
||||||
expect(heals).toEqual([20]);
|
|
||||||
expect(world.has(a, DamageCmd)).toBe(false);
|
|
||||||
expect(world.has(b, HealCmd)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op when no command entities exist", () => {
|
|
||||||
queue.handle(DamageCmd, () => {
|
|
||||||
throw new Error("should not be called");
|
|
||||||
});
|
|
||||||
|
|
||||||
// No entities with DamageCmd
|
|
||||||
|
|
||||||
expect(() => queue.execute()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op when no handlers are registered", () => {
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, DamageCmd, { amount: 10 });
|
|
||||||
|
|
||||||
expect(() => queue.execute()).not.toThrow();
|
|
||||||
expect(world.has(e, DamageCmd)).toBe(true); // not consumed
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Interruption ────────────────────────────────────
|
|
||||||
describe("CommandQueue interruption", () => {
|
|
||||||
let world: World;
|
|
||||||
let queue: CommandQueue;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
world = new World();
|
|
||||||
queue = new CommandQueue(world);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips processing when interrupted", () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
queue.handle(DamageCmd, handler);
|
|
||||||
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, DamageCmd, { amount: 10 });
|
|
||||||
|
|
||||||
// Interrupt with a never-resolving promise
|
|
||||||
queue.interrupt(new Promise(() => {}));
|
|
||||||
|
|
||||||
expect(queue.isInterrupted).toBe(true);
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
expect(world.has(e, DamageCmd)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resumes processing after interruption promise resolves", async () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
queue.handle(DamageCmd, handler);
|
|
||||||
|
|
||||||
let resolve!: () => void;
|
|
||||||
const promise = new Promise<void>((r) => {
|
|
||||||
resolve = r;
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.interrupt(promise);
|
|
||||||
expect(queue.isInterrupted).toBe(true);
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
await settled();
|
|
||||||
|
|
||||||
expect(queue.isInterrupted).toBe(false);
|
|
||||||
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, DamageCmd, { amount: 10 });
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resumes after all interruption promises settle (including rejections)", async () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
queue.handle(DamageCmd, handler);
|
|
||||||
|
|
||||||
queue.interrupt(Promise.reject(new Error("fail")));
|
|
||||||
await settled();
|
|
||||||
expect(queue.isInterrupted).toBe(false);
|
|
||||||
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, DamageCmd, { amount: 10 });
|
|
||||||
queue.execute();
|
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays interrupted while at least one of several promises is pending", async () => {
|
|
||||||
let resolveA!: () => void;
|
|
||||||
const a = new Promise<void>((r) => {
|
|
||||||
resolveA = r;
|
|
||||||
});
|
|
||||||
const b = Promise.resolve();
|
|
||||||
|
|
||||||
queue.interrupt(a);
|
|
||||||
queue.interrupt(b);
|
|
||||||
|
|
||||||
await settled(); // b resolves
|
|
||||||
expect(queue.isInterrupted).toBe(true); // a still pending
|
|
||||||
|
|
||||||
resolveA();
|
|
||||||
await settled();
|
|
||||||
expect(queue.isInterrupted).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Edge cases ──────────────────────────────────────
|
|
||||||
describe("CommandQueue edge cases", () => {
|
|
||||||
it("handler can safely add components to surviving entity", () => {
|
|
||||||
const world = new World();
|
|
||||||
const queue = new CommandQueue(world);
|
|
||||||
const Flag = defineComponent("flag", { set: false });
|
|
||||||
|
|
||||||
queue.handle(DamageCmd, (_cmd, entity) => {
|
|
||||||
world.add(entity!, Flag, { set: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, Health);
|
|
||||||
world.add(e, DamageCmd, { amount: 10 });
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(world.has(e, Flag)).toBe(true);
|
|
||||||
expect(world.isAlive(e)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handler can destroy a different entity", () => {
|
|
||||||
const world = new World();
|
|
||||||
const queue = new CommandQueue(world);
|
|
||||||
|
|
||||||
const other = world.spawn();
|
|
||||||
queue.handle(DamageCmd, () => {
|
|
||||||
world.destroy(other);
|
|
||||||
});
|
|
||||||
|
|
||||||
const e = world.spawn();
|
|
||||||
world.add(e, DamageCmd, { amount: 10 });
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(world.isAlive(other)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("chainable .handle() calls", () => {
|
|
||||||
const world = new World();
|
|
||||||
const queue = new CommandQueue(world);
|
|
||||||
|
|
||||||
const a = vi.fn();
|
|
||||||
const b = vi.fn();
|
|
||||||
|
|
||||||
queue
|
|
||||||
.handle(DamageCmd, (_cmd, _entity) => a())
|
|
||||||
.handle(HealCmd, (_cmd, _entity) => b());
|
|
||||||
|
|
||||||
const e1 = world.spawn();
|
|
||||||
world.add(e1, DamageCmd, { amount: 5 });
|
|
||||||
const e2 = world.spawn();
|
|
||||||
world.add(e2, HealCmd, { amount: 10 });
|
|
||||||
|
|
||||||
queue.execute();
|
|
||||||
|
|
||||||
expect(a).toHaveBeenCalled();
|
|
||||||
expect(b).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { defineConfig } from "tsup";
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: ["src/index.ts", "src/commands/index.ts", "src/bt/index.ts"],
|
entry: ['src/index.ts'],
|
||||||
format: ["esm", "cjs"],
|
format: ['esm', 'cjs'],
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue