@statedelta-actions/rules 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1218 -0
- package/dist/index.cjs +5 -0
- package/dist/index.d.cts +117 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +5 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
# @statedelta-actions/rules
|
|
2
|
+
|
|
3
|
+
> Rule Engine — superset layer on top of ActionEngine.
|
|
4
|
+
> Conditional trigger execution by priority.
|
|
5
|
+
>
|
|
6
|
+
> Rule = trigger. Events are a separate package (`@statedelta-actions/events`).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
|
|
12
|
+
1. [Philosophy](#philosophy)
|
|
13
|
+
2. [Architecture Overview](#architecture-overview)
|
|
14
|
+
3. [Installation & Setup](#installation--setup)
|
|
15
|
+
4. [Quick Start](#quick-start)
|
|
16
|
+
5. [Rule Definition](#rule-definition)
|
|
17
|
+
6. [Registration](#registration)
|
|
18
|
+
7. [Trigger Evaluation](#trigger-evaluation)
|
|
19
|
+
8. [Sub-rules (Conditional Cascade)](#sub-rules-conditional-cascade)
|
|
20
|
+
9. [Hooks (Governance)](#hooks-governance)
|
|
21
|
+
10. [Middleware (Params Enrichment)](#middleware-params-enrichment)
|
|
22
|
+
11. [JIT Compilation](#jit-compilation)
|
|
23
|
+
12. [Batch Operations](#batch-operations)
|
|
24
|
+
13. [Async Support](#async-support)
|
|
25
|
+
14. [Error Handling](#error-handling)
|
|
26
|
+
15. [HALT_HANDLER](#halt_handler)
|
|
27
|
+
16. [createInvokerMiddleware](#createinvokermiddleware)
|
|
28
|
+
17. [Full API Reference](#full-api-reference)
|
|
29
|
+
18. [Use Case: RPG Combat Tick](#use-case-rpg-combat-tick)
|
|
30
|
+
19. [Use Case: E-commerce Order Pipeline](#use-case-e-commerce-order-pipeline)
|
|
31
|
+
20. [Use Case: Deployment Approval Workflow](#use-case-deployment-approval-workflow)
|
|
32
|
+
21. [Performance Spectrum](#performance-spectrum)
|
|
33
|
+
22. [Internal Design Notes](#internal-design-notes)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Philosophy
|
|
39
|
+
|
|
40
|
+
### Rule = Trigger
|
|
41
|
+
|
|
42
|
+
A rule is not an executor. A rule is an **invoker** — a conditional trigger that, when matched, invokes an action through the ActionEngine. The RuleEngine does not execute directives. It normalizes rules into hidden actions at registration time, then delegates all execution to the ActionEngine.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Registration:
|
|
46
|
+
rule { id: "combat-heal", when: ..., then: [...] }
|
|
47
|
+
-> actionEngine.register([{ id: "rule:combat-heal", directives: [...] }])
|
|
48
|
+
|
|
49
|
+
Evaluation:
|
|
50
|
+
when(ctx) === true
|
|
51
|
+
-> actionEngine.invoke("rule:combat-heal", params)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
After registration, the RuleEngine is a **loop of `when() -> invoke()`**.
|
|
55
|
+
|
|
56
|
+
### RuleEngine receives, does not create
|
|
57
|
+
|
|
58
|
+
The RuleEngine receives an `IActionEngine<TCtx>` already instantiated and configured. It does not inject handlers, does not manipulate the access manifest, does not create the ActionEngine. The consumer is responsible for:
|
|
59
|
+
|
|
60
|
+
- Registering handlers (`dispatch`, `emit`, `halt`, custom)
|
|
61
|
+
- Setting limits, JIT mode
|
|
62
|
+
- Configuring the ActionAnalyzer separately if static analysis is needed
|
|
63
|
+
|
|
64
|
+
This separation means the same ActionEngine can serve both direct action invocations and rule-driven invocations.
|
|
65
|
+
|
|
66
|
+
### Hooks = governance, handlers = flow control
|
|
67
|
+
|
|
68
|
+
Hooks (`beforeRule`, `afterRule`) govern the rule loop: guards, observation, abort decisions. Flow control **inside** an action (halt, state locking, error abort) belongs to handlers in the ActionEngine. Two different layers with different responsibilities.
|
|
69
|
+
|
|
70
|
+
### Middleware = params enrichment, not ctx transformation
|
|
71
|
+
|
|
72
|
+
Middleware enriches the `params` envelope (execution scope), not the domain `ctx`. The `ctx` (TCtx) is domain state owned by the consumer — read-only for middleware.
|
|
73
|
+
|
|
74
|
+
### Never throws during evaluation
|
|
75
|
+
|
|
76
|
+
The engine never throws to the consumer during `evaluate()`. All runtime errors are collected in `RuleEvaluationResult.errors`. The only exception is a programmer error: calling `evaluate()` when `isAsync === true`.
|
|
77
|
+
|
|
78
|
+
### Feature not used = zero overhead
|
|
79
|
+
|
|
80
|
+
When hooks are not registered, hook code is never executed — not as dead branches, but as absent code paths. The JIT compiler conditionally emits code only for features that are actually configured. Zero middleware means no middleware variables, no pipeline calls, no params allocation.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Architecture Overview
|
|
85
|
+
|
|
86
|
+
### Monorepo Position
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
@statedelta-actions/core <- shared types, slots, frame
|
|
90
|
+
|
|
|
91
|
+
@statedelta-actions/actions <- ActionEngine — runtime puro (directives, handlers, JIT)
|
|
92
|
+
|
|
|
93
|
+
@statedelta-actions/rules <- RuleEngine (this package)
|
|
94
|
+
@statedelta-actions/events <- EventProcessor (separate package)
|
|
95
|
+
|
|
96
|
+
@statedelta-actions/graph <- dependency graph (consumed by analyzer, not by actions/rules)
|
|
97
|
+
@statedelta-actions/analyzer <- ActionAnalyzer — static analysis, capabilities (opt-in)
|
|
98
|
+
|
|
99
|
+
(future) tick-runner / realm <- PPP: evaluate -> drain events -> repeat
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Composition Model
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
Consumer (tick-runner, game loop, business layer)
|
|
106
|
+
|
|
|
107
|
+
+-- configures ActionEngine (handlers, limits)
|
|
108
|
+
+-- creates RuleEngine(actionEngine, middleware, hooks)
|
|
109
|
+
+-- registers rules
|
|
110
|
+
+-- calls evaluate(ctx)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Module Structure
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
src/
|
|
117
|
+
+-- types.ts <- All types and interfaces + RuleEvaluatorFn
|
|
118
|
+
+-- engine.ts <- RuleEngineImpl + createRuleEngine + registry helpers (~470 lines)
|
|
119
|
+
+-- validate.ts <- validateRule, validateSubRule
|
|
120
|
+
+-- handlers.ts <- HALT_HANDLER
|
|
121
|
+
+-- middleware.ts <- createInvokerMiddleware (public) + runMiddleware (internal)
|
|
122
|
+
+-- index.ts <- Public re-exports
|
|
123
|
+
+-- eval/ <- Evaluation runtime
|
|
124
|
+
| +-- interpreter.ts <- Rule evaluation interpreter sync/async + result builders
|
|
125
|
+
| +-- jit.ts <- buildRuleExecutor + emitReturn codegen
|
|
126
|
+
| +-- sub-rules.ts <- evaluateSubRulesSync/Async
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Public Exports
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
// Factory
|
|
133
|
+
export { createRuleEngine } from "./engine";
|
|
134
|
+
|
|
135
|
+
// Handlers
|
|
136
|
+
export { HALT_HANDLER } from "./handlers";
|
|
137
|
+
|
|
138
|
+
// Middleware
|
|
139
|
+
export { createInvokerMiddleware } from "./middleware";
|
|
140
|
+
|
|
141
|
+
// Types
|
|
142
|
+
export type {
|
|
143
|
+
SubRuleDefinition,
|
|
144
|
+
RuleDefinition,
|
|
145
|
+
RuleRegisterResult,
|
|
146
|
+
RuleRegisterError,
|
|
147
|
+
RuleEvaluationResult,
|
|
148
|
+
RuleEvaluationContext,
|
|
149
|
+
RuleEngineConfig,
|
|
150
|
+
IRuleEngine,
|
|
151
|
+
RuleHooks,
|
|
152
|
+
RuleMiddleware,
|
|
153
|
+
} from "./types";
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Installation & Setup
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { createActionEngine } from "@statedelta-actions/actions";
|
|
162
|
+
import {
|
|
163
|
+
createRuleEngine,
|
|
164
|
+
HALT_HANDLER,
|
|
165
|
+
createInvokerMiddleware,
|
|
166
|
+
} from "@statedelta-actions/rules";
|
|
167
|
+
|
|
168
|
+
// 1. Configure ActionEngine with your handlers
|
|
169
|
+
const actionEngine = createActionEngine<MyCtx>({
|
|
170
|
+
handlers: {
|
|
171
|
+
dispatch: myDispatchHandler,
|
|
172
|
+
emit: myEmitHandler,
|
|
173
|
+
halt: HALT_HANDLER,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 2. Create RuleEngine, passing the ActionEngine
|
|
178
|
+
const ruleEngine = createRuleEngine<MyCtx>({
|
|
179
|
+
actionEngine,
|
|
180
|
+
middleware: [createInvokerMiddleware()], // optional
|
|
181
|
+
ruleHooks: { // optional
|
|
182
|
+
beforeRule: (rule, evalCtx) => { /* guard */ },
|
|
183
|
+
afterRule: (rule, result, evalCtx) => { /* observe */ },
|
|
184
|
+
onRulesComplete: (result) => { /* cleanup */ },
|
|
185
|
+
},
|
|
186
|
+
maxSubRuleDepth: 10, // default
|
|
187
|
+
mode: "auto", // "interpret" | "jit" | "auto" (default)
|
|
188
|
+
autoJitThreshold: 8, // default
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Quick Start
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// Register rules
|
|
198
|
+
ruleEngine.register([
|
|
199
|
+
{
|
|
200
|
+
id: "heal-when-low",
|
|
201
|
+
priority: 100,
|
|
202
|
+
when: (ctx) => ctx.hp < 50,
|
|
203
|
+
then: [
|
|
204
|
+
{ type: "dispatch", target: "hp", op: "inc", value: 20 },
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "regen-mp",
|
|
209
|
+
priority: 50,
|
|
210
|
+
when: (ctx) => ctx.mp < 100,
|
|
211
|
+
then: [
|
|
212
|
+
{ type: "dispatch", target: "mp", op: "inc", value: 5 },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
// Evaluate all trigger rules against current state
|
|
218
|
+
const result = ruleEngine.evaluate(ctx);
|
|
219
|
+
|
|
220
|
+
// result.success -> true if completed without abort
|
|
221
|
+
// result.matched -> ["heal-when-low", "regen-mp"] rule IDs
|
|
222
|
+
// result.counters -> { rulesEvaluated: 2, rulesMatched: 2, ... }
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Rule Definition
|
|
228
|
+
|
|
229
|
+
### SubRuleDefinition
|
|
230
|
+
|
|
231
|
+
Base type for sub-rules. No `priority` — sub-rules execute in **declaration order**.
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
interface SubRuleDefinition<TCtx> {
|
|
235
|
+
readonly id: string; // unique identifier
|
|
236
|
+
readonly when?: (ctx: TCtx) => boolean; // trigger condition
|
|
237
|
+
readonly then?: readonly Directive<TCtx>[]; // action directives
|
|
238
|
+
readonly rules?: readonly SubRuleDefinition<TCtx>[]; // nested sub-rules
|
|
239
|
+
readonly tags?: readonly string[];
|
|
240
|
+
readonly effects?: readonly string[];
|
|
241
|
+
readonly declarations?: Record<string, unknown>;
|
|
242
|
+
readonly metadata?: Record<string, unknown>;
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### RuleDefinition
|
|
247
|
+
|
|
248
|
+
Top-level rules extend `SubRuleDefinition` with mandatory `priority`.
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
interface RuleDefinition<TCtx> extends SubRuleDefinition<TCtx> {
|
|
252
|
+
readonly priority: number; // higher = first (desc order)
|
|
253
|
+
readonly rules?: readonly SubRuleDefinition<TCtx>[]; // sub-rules (no priority)
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
A rule **must** have `when` (trigger condition).
|
|
258
|
+
|
|
259
|
+
A rule **must** have `then` or `rules` (or both). A rule with only `rules` and no `then` is a **group gate** — a pure conditional container.
|
|
260
|
+
|
|
261
|
+
### Priority
|
|
262
|
+
|
|
263
|
+
Higher number = executes first (like z-index). Rules are sorted by priority descending at registration time. Sub-rules execute in **declaration order** — they don't have `priority`.
|
|
264
|
+
|
|
265
|
+
### Validation
|
|
266
|
+
|
|
267
|
+
| Check | Error Code |
|
|
268
|
+
|-------|-----------|
|
|
269
|
+
| `id` must be a non-empty string | `INVALID_RULE` |
|
|
270
|
+
| `priority` must be a number | `INVALID_RULE` |
|
|
271
|
+
| Must have `when` function | `INVALID_RULE` |
|
|
272
|
+
| Must have `then` or `rules` (or both) | `INVALID_RULE` |
|
|
273
|
+
| Duplicate ID | `DUPLICATE_ID` |
|
|
274
|
+
|
|
275
|
+
Sub-rule validation:
|
|
276
|
+
|
|
277
|
+
| Check | Error Code |
|
|
278
|
+
|-------|-----------|
|
|
279
|
+
| `id` must be a non-empty string | `INVALID_RULE` |
|
|
280
|
+
| Must have `then` or `rules` (or both) | `INVALID_RULE` |
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Registration
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
const result = ruleEngine.register([rule1, rule2, ...]);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Registration pipeline
|
|
291
|
+
|
|
292
|
+
1. **Validate** each rule (id, priority, when, then/rules)
|
|
293
|
+
2. **Normalize** to hidden action: `rule:{id}`
|
|
294
|
+
3. **Collect sub-rules** recursively with dot-separated IDs
|
|
295
|
+
4. **Index** in internal registries (`_rules` sorted by priority desc)
|
|
296
|
+
5. **Register** hidden actions in ActionEngine
|
|
297
|
+
6. **Return** merged result
|
|
298
|
+
|
|
299
|
+
### RuleRegisterResult
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
interface RuleRegisterResult {
|
|
303
|
+
readonly registered: readonly string[];
|
|
304
|
+
readonly errors: readonly RuleRegisterError[];
|
|
305
|
+
readonly warnings: readonly RegisterWarning[];
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Unregister
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
const removed = ruleEngine.unregister("rule-id"); // true if found and removed
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Removes the rule from all internal registries, unregisters the hidden action from ActionEngine, and recursively cleans up sub-rules.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Trigger Evaluation
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
const result = ruleEngine.evaluate(ctx);
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Processes all rules in priority descending order:
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
evaluate(ctx)
|
|
329
|
+
|
|
|
330
|
+
actionEngine.setContext(ctx)
|
|
331
|
+
|
|
|
332
|
+
for each rule (priority desc):
|
|
333
|
+
|
|
|
334
|
+
+-- beforeRule hook -> "skip" -> skipped | "abort" -> return | void -> continue
|
|
335
|
+
+-- when(ctx) -> false -> notMatched | error -> collect, notMatched
|
|
336
|
+
+-- matched
|
|
337
|
+
+-- Middleware pipeline -> params (or error -> collect, skip invoke)
|
|
338
|
+
+-- Invoke action (if has then) -> DirectiveResult
|
|
339
|
+
+-- afterRule hook -> "abort" -> return | void -> continue
|
|
340
|
+
+-- Halt check -> aborted -> return
|
|
341
|
+
+-- Sub-rules cascade -> aborted -> return
|
|
342
|
+
|
|
|
343
|
+
onRulesComplete(result)
|
|
344
|
+
return result
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### RuleEvaluationResult
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
interface RuleEvaluationResult {
|
|
351
|
+
readonly success: boolean; // true if completed without abort
|
|
352
|
+
readonly aborted: boolean; // true if stopped early
|
|
353
|
+
readonly abortedBy?: string; // "beforeRule" | "afterRule" | "sub-rule" | "halt" | handler
|
|
354
|
+
readonly matched: readonly string[]; // IDs of matched rules
|
|
355
|
+
readonly skipped: readonly string[]; // IDs skipped by beforeRule
|
|
356
|
+
readonly notMatched: readonly string[];
|
|
357
|
+
readonly errors: readonly RuleError[];
|
|
358
|
+
readonly processedCount: number; // rules processed (< totalCount on abort)
|
|
359
|
+
readonly totalCount: number;
|
|
360
|
+
readonly counters: FrameCounters; // accumulated across all nesting
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Example
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
const ruleEngine = createRuleEngine({ actionEngine });
|
|
368
|
+
|
|
369
|
+
ruleEngine.register([
|
|
370
|
+
{
|
|
371
|
+
id: "shield",
|
|
372
|
+
priority: 200,
|
|
373
|
+
when: (ctx) => ctx.inCombat,
|
|
374
|
+
then: [{ type: "dispatch", target: "defense", op: "inc", value: 10 }],
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
id: "heal",
|
|
378
|
+
priority: 100,
|
|
379
|
+
when: (ctx) => ctx.hp < 50,
|
|
380
|
+
then: [{ type: "dispatch", target: "hp", op: "inc", value: 20 }],
|
|
381
|
+
},
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
const ctx = { inCombat: true, hp: 30, defense: 0 };
|
|
385
|
+
const result = ruleEngine.evaluate(ctx);
|
|
386
|
+
|
|
387
|
+
// shield (200) executes first, then heal (100)
|
|
388
|
+
// ctx.defense === 10, ctx.hp === 50
|
|
389
|
+
// result.matched === ["shield", "heal"]
|
|
390
|
+
// result.counters.rulesMatched === 2
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Sub-rules (Conditional Cascade)
|
|
396
|
+
|
|
397
|
+
Sub-rules enable conditional branching within a rule. After the parent's `then` execute, each sub-rule's `when()` is evaluated in **declaration order**. Sub-rules can nest to arbitrary depth (bounded by `maxSubRuleDepth`, default 10).
|
|
398
|
+
|
|
399
|
+
### Registration
|
|
400
|
+
|
|
401
|
+
Sub-rules are registered recursively as hidden actions with dot-separated IDs:
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
rule:combat-heal <- parent
|
|
405
|
+
rule:combat-heal.low-hp <- sub-rule
|
|
406
|
+
rule:combat-heal.low-hp.crit <- nested sub-rule
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Group gates (no `then`) register no action — `actionId = ""`.
|
|
410
|
+
|
|
411
|
+
### Evaluation
|
|
412
|
+
|
|
413
|
+
```
|
|
414
|
+
After parent invoke (or directly for group gate):
|
|
415
|
+
for each sub-rule (declaration order):
|
|
416
|
+
+-- when(ctx) -> false -> skip | undefined -> unconditional
|
|
417
|
+
+-- Invoke (if has then) -> aborted -> propagate up
|
|
418
|
+
+-- Recurse (if has sub-rules) -> aborted -> propagate up
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Group gates
|
|
422
|
+
|
|
423
|
+
A group gate is a rule with `rules` but no `then`. It acts as a pure conditional container:
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
{
|
|
427
|
+
id: "combat-group",
|
|
428
|
+
priority: 100,
|
|
429
|
+
when: (ctx) => ctx.zone === "combat",
|
|
430
|
+
// no `then` — this is a gate
|
|
431
|
+
rules: [
|
|
432
|
+
{ id: "heal", when: (ctx) => ctx.hp < 50, then: [...] },
|
|
433
|
+
{ id: "buff", when: (ctx) => ctx.mp > 0, then: [...] },
|
|
434
|
+
],
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
The gate evaluates `when()`. If true, children are evaluated. No action is invoked for the gate itself.
|
|
439
|
+
|
|
440
|
+
### Design decisions
|
|
441
|
+
|
|
442
|
+
| Decision | Rationale |
|
|
443
|
+
|----------|-----------|
|
|
444
|
+
| Declaration order, not priority | Sub-rules are a cascade within one rule — order matters semantically |
|
|
445
|
+
| Hooks do NOT apply to sub-rules | Hooks govern the main loop, not internal branching |
|
|
446
|
+
| Middleware params inherited | Parent's middleware-enriched `params` propagate as-is |
|
|
447
|
+
| Abort propagates upward | Sub-rule halt -> parent aborts -> main loop stops |
|
|
448
|
+
| Depth limited | Default 10. Exceeding -> error collected, sub-tree skipped (no abort) |
|
|
449
|
+
|
|
450
|
+
### Example: tiered discounts
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
ruleEngine.register([
|
|
454
|
+
{
|
|
455
|
+
id: "discount-engine",
|
|
456
|
+
priority: 200,
|
|
457
|
+
when: (ctx) => ctx.status === "validated" && ctx.discount === 0,
|
|
458
|
+
then: [{ type: "log", message: "evaluating discounts" }],
|
|
459
|
+
rules: [
|
|
460
|
+
{
|
|
461
|
+
id: "vip-discount",
|
|
462
|
+
when: (ctx) => ctx.customerTier === "vip",
|
|
463
|
+
then: [
|
|
464
|
+
{ type: "dispatch", target: "discount", op: "set", value: 0,
|
|
465
|
+
resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.2) }) },
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
id: "gold-discount",
|
|
470
|
+
when: (ctx) => ctx.customerTier === "gold",
|
|
471
|
+
then: [
|
|
472
|
+
{ type: "dispatch", target: "discount", op: "set", value: 0,
|
|
473
|
+
resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.15) }) },
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
},
|
|
478
|
+
]);
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## Hooks (Governance)
|
|
484
|
+
|
|
485
|
+
Hooks are analyzed once at construction time via `analyzeSlots`. They govern rule evaluation only. Events have their own hooks in `@statedelta-actions/events`.
|
|
486
|
+
|
|
487
|
+
### Rule Hooks
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
ruleHooks: {
|
|
491
|
+
beforeRule?: (rule: RuleDefinition, evalCtx: RuleEvaluationContext) => "skip" | "abort" | void;
|
|
492
|
+
afterRule?: (rule: RuleDefinition, result: DirectiveResult, evalCtx: RuleEvaluationContext) => "abort" | void;
|
|
493
|
+
onRulesComplete?: (result: RuleEvaluationResult) => void;
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
| Hook | When | Returns | Error behavior |
|
|
498
|
+
|------|------|---------|----------------|
|
|
499
|
+
| `beforeRule` | Before `when()` evaluation | `"skip"` -> skipped, `"abort"` -> stop pipeline, `void` -> continue | Collected in `errors[]`, continue |
|
|
500
|
+
| `afterRule` | After invoke (if rule had `then`) | `"abort"` -> stop pipeline, `void` -> continue | Fire-and-forget |
|
|
501
|
+
| `onRulesComplete` | After all rules processed (or abort) | `void` | Silenced |
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
interface RuleEvaluationContext<TCtx> {
|
|
505
|
+
readonly ctx: TCtx;
|
|
506
|
+
readonly counters: FrameCounters;
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Example: governance by priority
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
const ruleEngine = createRuleEngine({
|
|
514
|
+
actionEngine,
|
|
515
|
+
ruleHooks: {
|
|
516
|
+
beforeRule: (rule) => {
|
|
517
|
+
if (rule.priority < 50) return "skip";
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### Example: abort on error
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
ruleHooks: {
|
|
527
|
+
afterRule: (rule, result) => {
|
|
528
|
+
if (result.errors.length > 0) return "abort";
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
---
|
|
534
|
+
|
|
535
|
+
## Middleware (Params Enrichment)
|
|
536
|
+
|
|
537
|
+
Middleware runs between match and invoke. It enriches the `params` that become the action's scope.
|
|
538
|
+
|
|
539
|
+
### Composition model
|
|
540
|
+
|
|
541
|
+
Delta-based. Each middleware receives the accumulated params and returns a delta to merge:
|
|
542
|
+
|
|
543
|
+
```
|
|
544
|
+
params0 = {} -> mw[0](rule, ctx, params0) -> d0 -> params1 = {...params0, ...d0}
|
|
545
|
+
mw[1](rule, ctx, params1) -> d1 -> params2 = {...params1, ...d1}
|
|
546
|
+
...
|
|
547
|
+
invoke(actionId, paramsN)
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Middleware cannot drop what predecessors injected (only overwrite by key).
|
|
551
|
+
|
|
552
|
+
### Signature
|
|
553
|
+
|
|
554
|
+
```typescript
|
|
555
|
+
type RuleMiddleware<TCtx> = (
|
|
556
|
+
rule: RuleDefinition<TCtx>,
|
|
557
|
+
ctx: TCtx,
|
|
558
|
+
params: Record<string, unknown>,
|
|
559
|
+
) => Record<string, unknown>;
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
### Sub-rule middleware
|
|
563
|
+
|
|
564
|
+
Middleware does **not** re-run for sub-rules. The parent's middleware-enriched `params` are passed directly to sub-rule invocations.
|
|
565
|
+
|
|
566
|
+
### Error handling
|
|
567
|
+
|
|
568
|
+
Middleware error -> collected in `errors[]`, invoke skipped, next rule continues.
|
|
569
|
+
|
|
570
|
+
### Example: custom audit middleware
|
|
571
|
+
|
|
572
|
+
```typescript
|
|
573
|
+
const auditMiddleware: RuleMiddleware<MyCtx> = (rule, ctx, params) => ({
|
|
574
|
+
$audit: {
|
|
575
|
+
ruleId: rule.id,
|
|
576
|
+
timestamp: Date.now(),
|
|
577
|
+
userId: ctx.currentUser.id,
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const ruleEngine = createRuleEngine({
|
|
582
|
+
actionEngine,
|
|
583
|
+
middleware: [auditMiddleware, createInvokerMiddleware()],
|
|
584
|
+
});
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
---
|
|
588
|
+
|
|
589
|
+
## JIT Compilation
|
|
590
|
+
|
|
591
|
+
JIT compiles the **rule iteration loop**, not individual directives (ActionEngine handles directive JIT separately). Two levels of JIT coexist: ActionEngine compiles directive execution, RuleEngine compiles rule orchestration.
|
|
592
|
+
|
|
593
|
+
### What is compiled
|
|
594
|
+
|
|
595
|
+
`buildRuleExecutor` generates a function via `new Function` that replaces the interpreter. Same signature — the engine swaps transparently.
|
|
596
|
+
|
|
597
|
+
### Conditional code emission
|
|
598
|
+
|
|
599
|
+
The generated JS only contains code for features that are actually configured:
|
|
600
|
+
|
|
601
|
+
| Feature absent | Code not emitted |
|
|
602
|
+
|---|---|
|
|
603
|
+
| `beforeRule` | Entire try/catch block, skip/abort checks |
|
|
604
|
+
| `afterRule` | Entire try/catch block, abort check |
|
|
605
|
+
| `onRulesComplete` | Final call + cleanup blocks |
|
|
606
|
+
| No hooks at all | `evalCtx` not created, no hook variables |
|
|
607
|
+
| No middleware | `params` variable not declared, no pipeline call |
|
|
608
|
+
|
|
609
|
+
### Tier 0 — tight loop
|
|
610
|
+
|
|
611
|
+
With zero hooks and zero middleware, the generated code is a minimal loop:
|
|
612
|
+
|
|
613
|
+
```
|
|
614
|
+
for -> when(ctx) -> invoke(actionId) -> halt check -> sub-rules -> next
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
No try/catch for hooks. No middleware pipeline. No evalCtx allocation.
|
|
618
|
+
|
|
619
|
+
### Modes
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
const engine = createRuleEngine({ actionEngine, mode: "auto" });
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
| Mode | Behavior |
|
|
626
|
+
|------|----------|
|
|
627
|
+
| `interpret` | Always use interpreter. JIT never activates. `compile()` is a no-op. |
|
|
628
|
+
| `jit` | Compile immediately at construction. No interpreter phase. |
|
|
629
|
+
| `auto` (default) | Start with interpreter. Promote after threshold calls. |
|
|
630
|
+
|
|
631
|
+
Default threshold: 8 `evaluate()` calls. Configurable via `autoJitThreshold`.
|
|
632
|
+
|
|
633
|
+
```
|
|
634
|
+
evaluate() #1..#7 -> interpreter
|
|
635
|
+
evaluate() #8 -> compile + swap interpreter for JIT
|
|
636
|
+
evaluate() #9+ -> JIT compiled
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### compile()
|
|
640
|
+
|
|
641
|
+
```typescript
|
|
642
|
+
engine.compile(); // forces immediate promotion to JIT
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Useful for warmup. No-op if `mode === "interpret"`.
|
|
646
|
+
|
|
647
|
+
### compilationMode getter
|
|
648
|
+
|
|
649
|
+
```typescript
|
|
650
|
+
engine.compilationMode // "interpret" | "jit"
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
Reflects current state — switches from `"interpret"` to `"jit"` after promotion.
|
|
654
|
+
|
|
655
|
+
---
|
|
656
|
+
|
|
657
|
+
## Batch Operations
|
|
658
|
+
|
|
659
|
+
### Callback-based (recommended)
|
|
660
|
+
|
|
661
|
+
```typescript
|
|
662
|
+
const result = engine.batch((eng) => {
|
|
663
|
+
eng.register([rule1, rule2]);
|
|
664
|
+
eng.register([rule3, rule4]);
|
|
665
|
+
});
|
|
666
|
+
// endBatch() called automatically, even on throw
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
`batch(fn)` guarantees cleanup: if `fn` throws, `endBatch()` is still called to avoid leaving the engine in an inconsistent state.
|
|
670
|
+
|
|
671
|
+
### Manual
|
|
672
|
+
|
|
673
|
+
```typescript
|
|
674
|
+
engine.beginBatch();
|
|
675
|
+
engine.register([rule1, rule2]);
|
|
676
|
+
engine.register([rule3, rule4]);
|
|
677
|
+
const result = engine.endBatch();
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
- Delegates `beginBatch()`/`endBatch()` to ActionEngine
|
|
681
|
+
- Accumulates registration results across multiple `register()` calls
|
|
682
|
+
- Merges everything on `endBatch()`
|
|
683
|
+
- Supports nesting: inner `endBatch()` returns empty, outer merges all
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
## Async Support
|
|
688
|
+
|
|
689
|
+
### Detection
|
|
690
|
+
|
|
691
|
+
```typescript
|
|
692
|
+
const isAsync = ruleHookAnalysis.hasAnyAsync || actionEngine.isAsync;
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
Computed once at construction. Immutable. If any rule hook is async, or if the ActionEngine has async directive hooks, `isAsync === true`.
|
|
696
|
+
|
|
697
|
+
### Usage
|
|
698
|
+
|
|
699
|
+
```typescript
|
|
700
|
+
// Sync (throws if isAsync)
|
|
701
|
+
const result = engine.evaluate(ctx);
|
|
702
|
+
|
|
703
|
+
// Async (always works)
|
|
704
|
+
const result = await engine.evaluateAsync(ctx);
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
Calling `evaluate()` when `isAsync === true` throws an error — must use `evaluateAsync()`. This is a guard against accidentally dropping promises.
|
|
708
|
+
|
|
709
|
+
### Async hooks
|
|
710
|
+
|
|
711
|
+
```typescript
|
|
712
|
+
const ruleEngine = createRuleEngine({
|
|
713
|
+
actionEngine,
|
|
714
|
+
ruleHooks: {
|
|
715
|
+
beforeRule: async (rule, evalCtx) => {
|
|
716
|
+
const allowed = await checkPermission(rule.id);
|
|
717
|
+
if (!allowed) return "skip";
|
|
718
|
+
},
|
|
719
|
+
afterRule: async (rule, result) => {
|
|
720
|
+
await logToExternalService(rule.id, result);
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// Must use evaluateAsync
|
|
726
|
+
const result = await ruleEngine.evaluateAsync(ctx);
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
|
|
731
|
+
## Error Handling
|
|
732
|
+
|
|
733
|
+
The engine **never throws** during evaluation. All errors are collected:
|
|
734
|
+
|
|
735
|
+
| Error source | Behavior |
|
|
736
|
+
|-------------|----------|
|
|
737
|
+
| `when()` throws | Error collected, rule treated as `notMatched`, continue |
|
|
738
|
+
| `beforeRule` throws | Error collected, continue (treated as void) |
|
|
739
|
+
| `afterRule` throws | Fire-and-forget, continue |
|
|
740
|
+
| `onRulesComplete` throws | Silenced |
|
|
741
|
+
| Middleware throws | Error collected, invoke skipped, next rule |
|
|
742
|
+
| ActionEngine invoke errors | Directive errors accumulated in counters |
|
|
743
|
+
| Sub-rule `when()` throws | Error collected, sub-rule skipped |
|
|
744
|
+
| `maxSubRuleDepth` exceeded | Error collected, sub-tree skipped (no abort) |
|
|
745
|
+
|
|
746
|
+
### Inspecting errors
|
|
747
|
+
|
|
748
|
+
```typescript
|
|
749
|
+
const result = engine.evaluate(ctx);
|
|
750
|
+
|
|
751
|
+
for (const err of result.errors) {
|
|
752
|
+
console.log(`Rule index ${err.ruleIndex}: ${err.error}`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Consumer decides what errors mean:
|
|
756
|
+
if (result.errors.length > 0) {
|
|
757
|
+
// handle errors
|
|
758
|
+
}
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
---
|
|
762
|
+
|
|
763
|
+
## HALT_HANDLER
|
|
764
|
+
|
|
765
|
+
A built-in handler that signals the ActionEngine to abort directive execution. The RuleEngine's interpreter checks `abortedBy === "halt"` as a controlled stop.
|
|
766
|
+
|
|
767
|
+
```typescript
|
|
768
|
+
import { HALT_HANDLER } from "@statedelta-actions/rules";
|
|
769
|
+
|
|
770
|
+
const actionEngine = createActionEngine({
|
|
771
|
+
handlers: {
|
|
772
|
+
...myHandlers,
|
|
773
|
+
halt: HALT_HANDLER,
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
Usage in directives:
|
|
779
|
+
|
|
780
|
+
```typescript
|
|
781
|
+
{
|
|
782
|
+
id: "guard",
|
|
783
|
+
priority: 500,
|
|
784
|
+
when: (ctx) => ctx.hp <= 0,
|
|
785
|
+
then: [
|
|
786
|
+
{ type: "dispatch", target: "status", op: "set", value: "dead" },
|
|
787
|
+
{ type: "halt" }, // stops all remaining rules
|
|
788
|
+
],
|
|
789
|
+
}
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
When halt fires:
|
|
793
|
+
- `result.aborted === true`
|
|
794
|
+
- `result.abortedBy === "halt"`
|
|
795
|
+
- `result.success === true` (halt is a controlled stop, not an error)
|
|
796
|
+
- Remaining rules are not evaluated
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
## createInvokerMiddleware
|
|
801
|
+
|
|
802
|
+
Built-in, opt-in middleware that injects `$invoker` metadata into the action scope:
|
|
803
|
+
|
|
804
|
+
```typescript
|
|
805
|
+
import { createInvokerMiddleware } from "@statedelta-actions/rules";
|
|
806
|
+
|
|
807
|
+
const ruleEngine = createRuleEngine({
|
|
808
|
+
actionEngine,
|
|
809
|
+
middleware: [createInvokerMiddleware()],
|
|
810
|
+
});
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
### What it injects
|
|
814
|
+
|
|
815
|
+
```typescript
|
|
816
|
+
{
|
|
817
|
+
$invoker: {
|
|
818
|
+
ruleId: "combat-heal",
|
|
819
|
+
priority: 100,
|
|
820
|
+
tags: ["combat"],
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### Scope propagation
|
|
826
|
+
|
|
827
|
+
`$invoker` propagates to all sub-actions in the ActionEngine via prototype chain scope. If your handler invokes a child action, the child's `frame.scope.$invoker` inherits from the parent.
|
|
828
|
+
|
|
829
|
+
### Use case: audit trail
|
|
830
|
+
|
|
831
|
+
```typescript
|
|
832
|
+
const auditHandler = {
|
|
833
|
+
analyze: () => ({ capabilities: [], dependencies: [] }),
|
|
834
|
+
execute: (_d, frame) => {
|
|
835
|
+
const invoker = frame.scope.$invoker;
|
|
836
|
+
auditLog.push({
|
|
837
|
+
ruleId: invoker.ruleId,
|
|
838
|
+
priority: invoker.priority,
|
|
839
|
+
timestamp: Date.now(),
|
|
840
|
+
});
|
|
841
|
+
return { ok: true };
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
---
|
|
847
|
+
|
|
848
|
+
## Full API Reference
|
|
849
|
+
|
|
850
|
+
### IRuleEngine\<TCtx\>
|
|
851
|
+
|
|
852
|
+
```typescript
|
|
853
|
+
interface IRuleEngine<TCtx> {
|
|
854
|
+
// Registration
|
|
855
|
+
register(rules: readonly RuleDefinition<TCtx>[]): RuleRegisterResult;
|
|
856
|
+
unregister(id: string): boolean;
|
|
857
|
+
|
|
858
|
+
// Trigger evaluation
|
|
859
|
+
evaluate(ctx: TCtx): RuleEvaluationResult;
|
|
860
|
+
evaluateAsync(ctx: TCtx): Promise<RuleEvaluationResult>;
|
|
861
|
+
|
|
862
|
+
// Batch
|
|
863
|
+
beginBatch(): void;
|
|
864
|
+
endBatch(): RuleRegisterResult;
|
|
865
|
+
batch(fn: (engine: IRuleEngine<TCtx>) => void): RuleRegisterResult;
|
|
866
|
+
|
|
867
|
+
// JIT
|
|
868
|
+
compile(): void;
|
|
869
|
+
|
|
870
|
+
// Introspection
|
|
871
|
+
has(id: string): boolean; // accepts qualified IDs for sub-rules
|
|
872
|
+
readonly size: number; // top-level rules count
|
|
873
|
+
|
|
874
|
+
// Accessors
|
|
875
|
+
readonly actionEngine: IActionEngine<TCtx>;
|
|
876
|
+
readonly isAsync: boolean;
|
|
877
|
+
readonly compilationMode: "interpret" | "jit";
|
|
878
|
+
}
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
### RuleEngineConfig\<TCtx\>
|
|
882
|
+
|
|
883
|
+
```typescript
|
|
884
|
+
interface RuleEngineConfig<TCtx> {
|
|
885
|
+
actionEngine: IActionEngine<TCtx>; // required
|
|
886
|
+
middleware?: readonly RuleMiddleware<TCtx>[]; // optional, default []
|
|
887
|
+
ruleHooks?: RuleHooks<TCtx>; // optional
|
|
888
|
+
maxSubRuleDepth?: number; // optional, default 10
|
|
889
|
+
mode?: "interpret" | "jit" | "auto"; // optional, default "auto"
|
|
890
|
+
autoJitThreshold?: number; // optional, default 8
|
|
891
|
+
}
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
### FrameCounters
|
|
895
|
+
|
|
896
|
+
```typescript
|
|
897
|
+
interface FrameCounters {
|
|
898
|
+
rulesEvaluated: number;
|
|
899
|
+
rulesMatched: number;
|
|
900
|
+
rulesSkipped: number;
|
|
901
|
+
directivesApplied: number;
|
|
902
|
+
directivesSkipped: number;
|
|
903
|
+
subRunsCreated: number;
|
|
904
|
+
errors: number;
|
|
905
|
+
}
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
## Use Case: RPG Combat Tick
|
|
911
|
+
|
|
912
|
+
A game tick that evaluates combat rules with sub-rules and halt.
|
|
913
|
+
|
|
914
|
+
```typescript
|
|
915
|
+
interface GameCtx {
|
|
916
|
+
hp: number;
|
|
917
|
+
maxHp: number;
|
|
918
|
+
mp: number;
|
|
919
|
+
zone: string;
|
|
920
|
+
effects: string[];
|
|
921
|
+
log: string[];
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// --- ActionEngine setup ---
|
|
925
|
+
|
|
926
|
+
const actionEngine = createActionEngine<GameCtx>({
|
|
927
|
+
handlers: {
|
|
928
|
+
dispatch: dispatchHandler,
|
|
929
|
+
emit: emitHandler,
|
|
930
|
+
log: logHandler,
|
|
931
|
+
halt: HALT_HANDLER,
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// --- RuleEngine setup ---
|
|
936
|
+
|
|
937
|
+
const ruleEngine = createRuleEngine<GameCtx>({
|
|
938
|
+
actionEngine,
|
|
939
|
+
middleware: [createInvokerMiddleware()],
|
|
940
|
+
ruleHooks: {
|
|
941
|
+
beforeRule: (rule) => {
|
|
942
|
+
if (rule.priority < 50) return "skip";
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
// --- Rules ---
|
|
948
|
+
|
|
949
|
+
ruleEngine.register([
|
|
950
|
+
// High-priority death check — halts everything
|
|
951
|
+
{
|
|
952
|
+
id: "death-check",
|
|
953
|
+
priority: 500,
|
|
954
|
+
when: (ctx) => ctx.hp <= 0,
|
|
955
|
+
then: [
|
|
956
|
+
{ type: "log", message: "dead — halting" },
|
|
957
|
+
{ type: "halt" },
|
|
958
|
+
],
|
|
959
|
+
},
|
|
960
|
+
|
|
961
|
+
// Combat zone rules with sub-rules
|
|
962
|
+
{
|
|
963
|
+
id: "combat-zone",
|
|
964
|
+
priority: 100,
|
|
965
|
+
when: (ctx) => ctx.zone === "combat",
|
|
966
|
+
then: [
|
|
967
|
+
{ type: "dispatch", target: "hp", op: "dec", value: 15 },
|
|
968
|
+
{ type: "emit", event: "damage-tick" },
|
|
969
|
+
],
|
|
970
|
+
rules: [
|
|
971
|
+
{
|
|
972
|
+
id: "low-hp-heal",
|
|
973
|
+
when: (ctx) => ctx.hp < 50,
|
|
974
|
+
then: [
|
|
975
|
+
{ type: "dispatch", target: "hp", op: "inc", value: 5 },
|
|
976
|
+
],
|
|
977
|
+
},
|
|
978
|
+
],
|
|
979
|
+
},
|
|
980
|
+
|
|
981
|
+
// MP regen
|
|
982
|
+
{
|
|
983
|
+
id: "mp-regen",
|
|
984
|
+
priority: 50,
|
|
985
|
+
when: (ctx) => ctx.mp < 100,
|
|
986
|
+
then: [
|
|
987
|
+
{ type: "dispatch", target: "mp", op: "inc", value: 3 },
|
|
988
|
+
],
|
|
989
|
+
},
|
|
990
|
+
]);
|
|
991
|
+
|
|
992
|
+
// --- Tick ---
|
|
993
|
+
|
|
994
|
+
const ctx: GameCtx = { hp: 40, maxHp: 100, mp: 30, zone: "combat", effects: [], log: [] };
|
|
995
|
+
|
|
996
|
+
const result = ruleEngine.evaluate(ctx);
|
|
997
|
+
// combat-zone fires: hp 40->25, emits "damage-tick"
|
|
998
|
+
// low-hp-heal fires: hp 25->30
|
|
999
|
+
// mp-regen fires: mp 30->33
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
---
|
|
1003
|
+
|
|
1004
|
+
## Use Case: E-commerce Order Pipeline
|
|
1005
|
+
|
|
1006
|
+
An order processing pipeline with validation, tiered discounts, and fraud detection.
|
|
1007
|
+
|
|
1008
|
+
```typescript
|
|
1009
|
+
interface OrderCtx {
|
|
1010
|
+
orderId: string;
|
|
1011
|
+
status: string;
|
|
1012
|
+
items: Array<{ sku: string; qty: number; price: number }>;
|
|
1013
|
+
subtotal: number;
|
|
1014
|
+
discount: number;
|
|
1015
|
+
total: number;
|
|
1016
|
+
customerTier: string;
|
|
1017
|
+
coupon: string | null;
|
|
1018
|
+
paymentMethod: string;
|
|
1019
|
+
flags: string[];
|
|
1020
|
+
log: string[];
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
ruleEngine.register([
|
|
1024
|
+
// 1. Fraud detection — highest priority, halts pipeline
|
|
1025
|
+
{
|
|
1026
|
+
id: "fraud-check",
|
|
1027
|
+
priority: 500,
|
|
1028
|
+
when: (ctx) => ctx.subtotal > 1000 && ctx.customerTier === "bronze",
|
|
1029
|
+
then: [
|
|
1030
|
+
{ type: "dispatch", target: "flags", op: "push", value: "fraud-review" },
|
|
1031
|
+
{ type: "dispatch", target: "status", op: "set", value: "held" },
|
|
1032
|
+
{ type: "halt" },
|
|
1033
|
+
],
|
|
1034
|
+
},
|
|
1035
|
+
|
|
1036
|
+
// 2. Calculate subtotal
|
|
1037
|
+
{
|
|
1038
|
+
id: "calc-subtotal",
|
|
1039
|
+
priority: 400,
|
|
1040
|
+
when: (ctx) => ctx.status === "pending",
|
|
1041
|
+
then: [
|
|
1042
|
+
{
|
|
1043
|
+
type: "dispatch", target: "subtotal", op: "set", value: 0,
|
|
1044
|
+
resolve: (ctx) => ({
|
|
1045
|
+
value: ctx.items.reduce((sum, i) => sum + i.qty * i.price, 0),
|
|
1046
|
+
}),
|
|
1047
|
+
},
|
|
1048
|
+
{ type: "dispatch", target: "status", op: "set", value: "validated" },
|
|
1049
|
+
],
|
|
1050
|
+
},
|
|
1051
|
+
|
|
1052
|
+
// 3. Tiered discounts via sub-rules
|
|
1053
|
+
{
|
|
1054
|
+
id: "discount-engine",
|
|
1055
|
+
priority: 300,
|
|
1056
|
+
when: (ctx) => ctx.status === "validated" && ctx.discount === 0,
|
|
1057
|
+
then: [{ type: "log", message: "evaluating discounts" }],
|
|
1058
|
+
rules: [
|
|
1059
|
+
{
|
|
1060
|
+
id: "gold-discount",
|
|
1061
|
+
when: (ctx) => ctx.customerTier === "gold",
|
|
1062
|
+
then: [
|
|
1063
|
+
{ type: "dispatch", target: "discount", op: "set", value: 0,
|
|
1064
|
+
resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.15) }) },
|
|
1065
|
+
],
|
|
1066
|
+
},
|
|
1067
|
+
{
|
|
1068
|
+
id: "silver-discount",
|
|
1069
|
+
when: (ctx) => ctx.customerTier === "silver",
|
|
1070
|
+
then: [
|
|
1071
|
+
{ type: "dispatch", target: "discount", op: "set", value: 0,
|
|
1072
|
+
resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.1) }) },
|
|
1073
|
+
],
|
|
1074
|
+
},
|
|
1075
|
+
],
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
// 4. Payment
|
|
1079
|
+
{
|
|
1080
|
+
id: "process-payment",
|
|
1081
|
+
priority: 200,
|
|
1082
|
+
when: (ctx) => ctx.status === "validated",
|
|
1083
|
+
then: [
|
|
1084
|
+
{ type: "dispatch", target: "total", op: "set", value: 0,
|
|
1085
|
+
resolve: (ctx) => ({ value: ctx.subtotal - ctx.discount }) },
|
|
1086
|
+
{ type: "dispatch", target: "status", op: "set", value: "paid" },
|
|
1087
|
+
{ type: "emit", event: "payment-processed" },
|
|
1088
|
+
],
|
|
1089
|
+
},
|
|
1090
|
+
]);
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
---
|
|
1094
|
+
|
|
1095
|
+
## Use Case: Deployment Approval Workflow
|
|
1096
|
+
|
|
1097
|
+
A CI/CD pipeline with submission, reviewer assignment, approval gates, and deploy strategy selection via sub-rules.
|
|
1098
|
+
|
|
1099
|
+
```typescript
|
|
1100
|
+
interface WorkflowCtx {
|
|
1101
|
+
id: string;
|
|
1102
|
+
status: string;
|
|
1103
|
+
type: string;
|
|
1104
|
+
environment: string;
|
|
1105
|
+
author: string;
|
|
1106
|
+
reviewers: string[];
|
|
1107
|
+
approvals: string[];
|
|
1108
|
+
rejections: string[];
|
|
1109
|
+
requiredApprovals: number;
|
|
1110
|
+
riskScore: number;
|
|
1111
|
+
flags: string[];
|
|
1112
|
+
metadata: Record<string, unknown>;
|
|
1113
|
+
log: string[];
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
ruleEngine.register([
|
|
1117
|
+
// 1. Rejection check — highest priority, halts
|
|
1118
|
+
{
|
|
1119
|
+
id: "check-rejections",
|
|
1120
|
+
priority: 500,
|
|
1121
|
+
when: (ctx) => ctx.status === "in-review" && ctx.rejections.length > 0,
|
|
1122
|
+
then: [
|
|
1123
|
+
{ type: "dispatch", target: "status", op: "set", value: "rejected" },
|
|
1124
|
+
{ type: "halt" },
|
|
1125
|
+
],
|
|
1126
|
+
},
|
|
1127
|
+
|
|
1128
|
+
// 2. Submission with reviewer assignment via sub-rules
|
|
1129
|
+
{
|
|
1130
|
+
id: "submit",
|
|
1131
|
+
priority: 400,
|
|
1132
|
+
when: (ctx) => ctx.status === "draft",
|
|
1133
|
+
then: [
|
|
1134
|
+
{ type: "dispatch", target: "status", op: "set", value: "submitted" },
|
|
1135
|
+
],
|
|
1136
|
+
rules: [
|
|
1137
|
+
{
|
|
1138
|
+
id: "assign-feature-reviewers",
|
|
1139
|
+
when: (ctx) => ctx.type === "feature",
|
|
1140
|
+
then: [
|
|
1141
|
+
{ type: "dispatch", target: "reviewers", op: "push", value: "tech-lead" },
|
|
1142
|
+
{ type: "dispatch", target: "reviewers", op: "push", value: "senior-dev" },
|
|
1143
|
+
],
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
id: "assign-infra-reviewers",
|
|
1147
|
+
when: (ctx) => ctx.type === "infra",
|
|
1148
|
+
then: [
|
|
1149
|
+
{ type: "dispatch", target: "reviewers", op: "push", value: "devops-lead" },
|
|
1150
|
+
{ type: "dispatch", target: "reviewers", op: "push", value: "sre" },
|
|
1151
|
+
],
|
|
1152
|
+
},
|
|
1153
|
+
],
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1156
|
+
// 3. Deploy strategy by environment + risk via sub-rules
|
|
1157
|
+
{
|
|
1158
|
+
id: "deploy",
|
|
1159
|
+
priority: 100,
|
|
1160
|
+
when: (ctx) => ctx.status === "approved",
|
|
1161
|
+
then: [{ type: "dispatch", target: "status", op: "set", value: "deploying" }],
|
|
1162
|
+
rules: [
|
|
1163
|
+
{
|
|
1164
|
+
id: "deploy-production",
|
|
1165
|
+
when: (ctx) => ctx.environment === "production",
|
|
1166
|
+
then: [{ type: "log", message: "production deploy" }],
|
|
1167
|
+
rules: [
|
|
1168
|
+
{
|
|
1169
|
+
id: "canary-deploy",
|
|
1170
|
+
when: (ctx) => ctx.riskScore > 50,
|
|
1171
|
+
then: [
|
|
1172
|
+
{ type: "dispatch", target: "flags", op: "push", value: "canary" },
|
|
1173
|
+
],
|
|
1174
|
+
},
|
|
1175
|
+
{
|
|
1176
|
+
id: "blue-green-deploy",
|
|
1177
|
+
when: (ctx) => ctx.riskScore <= 50,
|
|
1178
|
+
then: [
|
|
1179
|
+
{ type: "dispatch", target: "flags", op: "push", value: "blue-green" },
|
|
1180
|
+
],
|
|
1181
|
+
},
|
|
1182
|
+
],
|
|
1183
|
+
},
|
|
1184
|
+
],
|
|
1185
|
+
},
|
|
1186
|
+
]);
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
---
|
|
1190
|
+
|
|
1191
|
+
## Performance Spectrum
|
|
1192
|
+
|
|
1193
|
+
```
|
|
1194
|
+
Tier 0 (no hooks, no middleware) Full (hooks + middleware + sub-rules)
|
|
1195
|
+
---------------------------------------------------------------------
|
|
1196
|
+
Zero allocations for hooks evalCtx created per evaluate()
|
|
1197
|
+
No try/catch try/catch per hook
|
|
1198
|
+
No params variable params allocated + middleware pipeline
|
|
1199
|
+
Minimal loop Full governance pipeline
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
The JIT compiler emits only the code paths that are configured. Tier 0 is the **common case** for high-performance scenarios (game loops, real-time).
|
|
1203
|
+
|
|
1204
|
+
---
|
|
1205
|
+
|
|
1206
|
+
## Internal Design Notes
|
|
1207
|
+
|
|
1208
|
+
For detailed internal architecture, see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
|
1209
|
+
|
|
1210
|
+
### Key decisions
|
|
1211
|
+
|
|
1212
|
+
- **Rules are hidden actions** with prefix `rule:`. Sub-rules use `.` separator.
|
|
1213
|
+
- **Sub-rules are always interpreted** — JIT compiles only the main loop.
|
|
1214
|
+
- **Single exit point** in the interpreter — `onRulesComplete` called exactly once.
|
|
1215
|
+
- **Closure counter + noop swap** for auto-promote — zero overhead post-JIT.
|
|
1216
|
+
- **`Object.assign` in-place** for middleware — 1 allocation vs N+1.
|
|
1217
|
+
- **Immutability by convention** — no `Object.freeze()` (measurable cost in hot paths).
|
|
1218
|
+
- **Events are a separate package** — `@statedelta-actions/events`. The RuleEngine does not know about events.
|