Skip to content

Operations ​

The Operation pattern provides an extensible, testable, replayable way to handle complex game actions. Use it when your game has many distinct operations that need guards, async support, or undo.

When to use ​

The Operation pattern is opt-in. It's for complex games only. Simple games can use engine helpers directly. Consider operations when:

  • You have 10+ distinct game actions
  • Actions need guard conditions (can this action happen now?)
  • You want undo/redo
  • Actions have async components (network, animation callbacks)

Registry and executor ​

typescript
import { OperationRegistry, OperationExecutor } from "quantum-forge-engine/operations";

const registry = new OperationRegistry();

// Register an operation
registry.register({
  key: "move",
  canApply: (ctx) => {
    const state = ctx.getState();
    return state.player.canMove;
  },
  apply: (ctx) => {
    const state = ctx.getState();
    const { dx, dy } = ctx.operation;  // the payload you passed in
    ctx.setState({
      ...state,
      player: { ...state.player, x: state.player.x + dx, y: state.player.y + dy },
    });
    return { type: "sync" };
  },
});

// Register an async operation
registry.register({
  key: "attack",
  canApply: (ctx) => ctx.getState().player.energy > 0,
  apply: (ctx) => {
    const state = ctx.getState();
    ctx.setState({
      ...state,
      player: { ...state.player, energy: state.player.energy - 10 },
    });
    return {
      type: "deferred",
      promise: animateAttack().then(() => {
        // Post-animation state update
        const s = ctx.getState();
        ctx.setState({ ...s, enemy: { ...s.enemy, health: s.enemy.health - 25 } });
      }),
    };
  },
});

// Execute
const executor = new OperationExecutor(registry);

executor.executeSync("move", context);       // fire-and-forget deferred
await executor.execute("attack", context);   // await deferred promise

Operation structure ​

Each handler has:

FieldTypeDescription
keystringUnique identifier
canApply(ctx) => booleanOptional guard: can this run now?
apply(ctx) => OperationResultExecute the operation

apply returns:

  • { type: "sync", effects? }: completed immediately
  • { type: "deferred", promise, effects? }: has async follow-up

executeSync() and execute() both return false when no handler is registered for the key or canApply returns false.

Context object ​

The executor passes your context object straight through to canApply and apply. The framework's OperationContext defines the fields it knows about:

typescript
interface OperationContext {
  readonly getState: () => any;
  readonly setState: (next: any) => void;
  readonly helpers: Readonly<Record<string, any>>;
  readonly logger?: LoggerInterface;
  readonly target?: any;      // what the operation acts on
  readonly operation?: any;   // the operation payload
  readonly emit?: (type: string, data: any) => void;
}

There is no params field. Payloads travel in operation and target, and games routinely extend the interface with their own fields:

typescript
interface GameContext extends OperationContext {
  maxQudits: number; // e.g. from getMaxQudits(), to check capacity before a split
}

Why operations? ​

  • Extensible: add new operations without touching core code
  • Testable: operations are data, easy to unit test
  • Replayable: store operations for undo and replay
  • Guarded: canApply prevents invalid state transitions
  • Async-friendly: the deferred result handles animation callbacks

Powered by Quantum Forge