From 9e788b135bbdfc2d246727429ee59355014288ad Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 1 Jun 2026 14:20:56 +0800 Subject: [PATCH] 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. --- package.json | 5 + src/commands/command-queue.ts | 143 +++++++++++++++++ src/commands/index.ts | 2 + src/world.ts | 10 ++ test/commands.test.ts | 284 ++++++++++++++++++++++++++++++++++ tsup.config.ts | 6 +- 6 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 src/commands/command-queue.ts create mode 100644 src/commands/index.ts create mode 100644 test/commands.test.ts diff --git a/package.json b/package.json index 8ba4cab..c3a541a 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/commands/command-queue.ts b/src/commands/command-queue.ts new file mode 100644 index 0000000..e53b993 --- /dev/null +++ b/src/commands/command-queue.ts @@ -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> = ( + command: T, + entity?: Entity, +) => void; + +/** Pending work: entity, its command data, and the handler to invoke. */ +interface Pending = any> { + entity: Entity; + handler: CommandHandler; + data: T; +} + +/** Registered handler bookkeeping. */ +interface Registration = any> { + def: ComponentDef; + query: Query; + handler: CommandHandler; +} + +// ── 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>(); + 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>( + def: ComponentDef, + handler: CommandHandler, + ): 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): 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); + } + } +} diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..ffcfaef --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,2 @@ +export { CommandQueue } from "./command-queue"; +export type { CommandHandler } from "./command-queue"; diff --git a/src/world.ts b/src/world.ts index 1653d9b..459fa06 100644 --- a/src/world.ts +++ b/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>( diff --git a/test/commands.test.ts b/test/commands.test.ts new file mode 100644 index 0000000..8596152 --- /dev/null +++ b/test/commands.test.ts @@ -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((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((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(); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index b0c0e74..7eb0020 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -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,