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:
parent
fd78e9ce6d
commit
9e788b135b
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { CommandQueue } from "./command-queue";
|
||||
export type { CommandHandler } from "./command-queue";
|
||||
10
src/world.ts
10
src/world.ts
|
|
@ -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>>(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue