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",
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"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);
|
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 ──────────────────────────
|
// ── Component operations ──────────────────────────
|
||||||
|
|
||||||
add<T extends Record<string, any>>(
|
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({
|
export default defineConfig({
|
||||||
entry: ['src/index.ts'],
|
entry: ["src/index.ts", "src/commands/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