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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user