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
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 promiseOperation structure
Each handler has:
| Field | Type | Description |
|---|---|---|
key | string | Unique identifier |
canApply | (ctx) => boolean | Optional guard: can this run now? |
apply | (ctx) => OperationResult | Execute 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:
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:
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:
canApplyprevents invalid state transitions - Async-friendly: the deferred result handles animation callbacks