feat: add CommandQueue for pull-based command processing

Introduces a `CommandQueue` to handle command components. It allows
registering handlers that are executed when `execute()` is called,
automatically removing the command component and cleaning up empty
entities. Includes an interruption mechanism to pause processing
during asynchronous operations.
This commit is contained in:
hypercross 2026-06-01 14:20:56 +08:00
parent fd78e9ce6d
commit 9e788b135b
6 changed files with 447 additions and 3 deletions

View File

@ -11,6 +11,11 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./commands": {
"types": "./dist/commands/index.d.ts",
"import": "./dist/commands/index.js",
"require": "./dist/commands/index.cjs"
}
},
"files": [

View File

@ -0,0 +1,143 @@
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);
}
}
}

2
src/commands/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { CommandQueue } from "./command-queue";
export type { CommandHandler } from "./command-queue";

View File

@ -110,6 +110,16 @@ export class World {
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;
for (const store of this._components.values()) {
if (store.has(idx)) return true;
}
return false;
}
// ── Component operations ──────────────────────────
add<T extends Record<string, any>>(

284
test/commands.test.ts Normal file
View File

@ -0,0 +1,284 @@
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();
});
});

View File

@ -1,8 +1,8 @@
import { defineConfig } from 'tsup';
import { defineConfig } from "tsup";
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
entry: ["src/index.ts", "src/commands/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: true,