@arcane-engine/runtime 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 +38 -0
- package/index.ts +19 -0
- package/package.json +53 -0
- package/src/agent/agent.test.ts +384 -0
- package/src/agent/describe.ts +72 -0
- package/src/agent/index.ts +20 -0
- package/src/agent/protocol.ts +125 -0
- package/src/agent/types.ts +73 -0
- package/src/pathfinding/astar.test.ts +208 -0
- package/src/pathfinding/astar.ts +193 -0
- package/src/pathfinding/index.ts +2 -0
- package/src/pathfinding/types.ts +21 -0
- package/src/physics/aabb.ts +54 -0
- package/src/physics/index.ts +2 -0
- package/src/rendering/animation.test.ts +119 -0
- package/src/rendering/animation.ts +132 -0
- package/src/rendering/audio.test.ts +33 -0
- package/src/rendering/audio.ts +70 -0
- package/src/rendering/camera.ts +35 -0
- package/src/rendering/index.ts +56 -0
- package/src/rendering/input.test.ts +70 -0
- package/src/rendering/input.ts +82 -0
- package/src/rendering/lighting.ts +38 -0
- package/src/rendering/loop.ts +21 -0
- package/src/rendering/sprites.ts +60 -0
- package/src/rendering/text.test.ts +91 -0
- package/src/rendering/text.ts +184 -0
- package/src/rendering/texture.ts +31 -0
- package/src/rendering/tilemap.ts +46 -0
- package/src/rendering/types.ts +54 -0
- package/src/rendering/validate.ts +132 -0
- package/src/state/error.test.ts +45 -0
- package/src/state/error.ts +20 -0
- package/src/state/index.ts +70 -0
- package/src/state/observe.test.ts +173 -0
- package/src/state/observe.ts +110 -0
- package/src/state/prng.test.ts +221 -0
- package/src/state/prng.ts +162 -0
- package/src/state/query.test.ts +208 -0
- package/src/state/query.ts +144 -0
- package/src/state/store.test.ts +211 -0
- package/src/state/store.ts +109 -0
- package/src/state/transaction.test.ts +235 -0
- package/src/state/transaction.ts +280 -0
- package/src/state/types.test.ts +33 -0
- package/src/state/types.ts +30 -0
- package/src/systems/index.ts +2 -0
- package/src/systems/system.test.ts +217 -0
- package/src/systems/system.ts +150 -0
- package/src/systems/types.ts +35 -0
- package/src/testing/harness.ts +271 -0
- package/src/testing/mock-renderer.test.ts +93 -0
- package/src/testing/mock-renderer.ts +178 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/primitives.test.ts +105 -0
- package/src/ui/primitives.ts +260 -0
- package/src/ui/types.ts +57 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import type { ArcaneError } from "./error.ts";
|
|
2
|
+
import { createError } from "./error.ts";
|
|
3
|
+
|
|
4
|
+
/** A mutation: a named, describable, applicable state change */
|
|
5
|
+
export type Mutation<S> = Readonly<{
|
|
6
|
+
type: string;
|
|
7
|
+
path: string;
|
|
8
|
+
description: string;
|
|
9
|
+
apply: (state: S) => S;
|
|
10
|
+
}>;
|
|
11
|
+
|
|
12
|
+
// --- Core mutation primitives ---
|
|
13
|
+
|
|
14
|
+
/** Set a value at a path */
|
|
15
|
+
export function set<S>(path: string, value: unknown): Mutation<S> {
|
|
16
|
+
return {
|
|
17
|
+
type: "set",
|
|
18
|
+
path,
|
|
19
|
+
description: `Set ${path} to ${JSON.stringify(value)}`,
|
|
20
|
+
apply: (state: S) => setAtPath(state, path, value) as S,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Update a value at a path with a function */
|
|
25
|
+
export function update<S>(
|
|
26
|
+
path: string,
|
|
27
|
+
fn: (current: unknown) => unknown,
|
|
28
|
+
): Mutation<S> {
|
|
29
|
+
return {
|
|
30
|
+
type: "update",
|
|
31
|
+
path,
|
|
32
|
+
description: `Update ${path}`,
|
|
33
|
+
apply: (state: S) => {
|
|
34
|
+
const current = getAtPath(state, path);
|
|
35
|
+
return setAtPath(state, path, fn(current)) as S;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Push an item onto an array at a path */
|
|
41
|
+
export function push<S>(path: string, item: unknown): Mutation<S> {
|
|
42
|
+
return {
|
|
43
|
+
type: "push",
|
|
44
|
+
path,
|
|
45
|
+
description: `Push item onto ${path}`,
|
|
46
|
+
apply: (state: S) => {
|
|
47
|
+
const arr = getAtPath(state, path);
|
|
48
|
+
if (!Array.isArray(arr)) {
|
|
49
|
+
throw new Error(`Expected array at path "${path}", got ${typeof arr}`);
|
|
50
|
+
}
|
|
51
|
+
return setAtPath(state, path, [...arr, item]) as S;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Remove items from an array at a path matching a predicate */
|
|
57
|
+
export function removeWhere<S>(
|
|
58
|
+
path: string,
|
|
59
|
+
predicate: (item: unknown) => boolean,
|
|
60
|
+
): Mutation<S> {
|
|
61
|
+
return {
|
|
62
|
+
type: "remove",
|
|
63
|
+
path,
|
|
64
|
+
description: `Remove matching items from ${path}`,
|
|
65
|
+
apply: (state: S) => {
|
|
66
|
+
const arr = getAtPath(state, path);
|
|
67
|
+
if (!Array.isArray(arr)) {
|
|
68
|
+
throw new Error(`Expected array at path "${path}", got ${typeof arr}`);
|
|
69
|
+
}
|
|
70
|
+
return setAtPath(
|
|
71
|
+
state,
|
|
72
|
+
path,
|
|
73
|
+
arr.filter((item) => !predicate(item)),
|
|
74
|
+
) as S;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Remove a key from an object at a path */
|
|
80
|
+
export function removeKey<S>(path: string): Mutation<S> {
|
|
81
|
+
const segments = path.split(".");
|
|
82
|
+
const key = segments.pop()!;
|
|
83
|
+
const parentPath = segments.join(".");
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
type: "remove",
|
|
87
|
+
path,
|
|
88
|
+
description: `Remove key "${key}" from ${parentPath || "root"}`,
|
|
89
|
+
apply: (state: S) => {
|
|
90
|
+
const parent = parentPath ? getAtPath(state, parentPath) : state;
|
|
91
|
+
if (typeof parent !== "object" || parent === null) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Expected object at path "${parentPath}", got ${typeof parent}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const { [key]: _, ...rest } = parent as Record<string, unknown>;
|
|
97
|
+
return (parentPath ? setAtPath(state, parentPath, rest) : rest) as S;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Diff ---
|
|
103
|
+
|
|
104
|
+
/** A single change entry */
|
|
105
|
+
export type DiffEntry = Readonly<{
|
|
106
|
+
path: string;
|
|
107
|
+
from: unknown;
|
|
108
|
+
to: unknown;
|
|
109
|
+
}>;
|
|
110
|
+
|
|
111
|
+
/** All changes from a transaction */
|
|
112
|
+
export type Diff = Readonly<{
|
|
113
|
+
entries: readonly DiffEntry[];
|
|
114
|
+
}>;
|
|
115
|
+
|
|
116
|
+
// --- Transaction result ---
|
|
117
|
+
|
|
118
|
+
/** An effect triggered by a state change (for observer/event routing) */
|
|
119
|
+
export type Effect = Readonly<{
|
|
120
|
+
type: string;
|
|
121
|
+
source: string;
|
|
122
|
+
data: Readonly<Record<string, unknown>>;
|
|
123
|
+
}>;
|
|
124
|
+
|
|
125
|
+
/** Result of executing a transaction */
|
|
126
|
+
export type TransactionResult<S> = Readonly<{
|
|
127
|
+
state: S;
|
|
128
|
+
diff: Diff;
|
|
129
|
+
effects: readonly Effect[];
|
|
130
|
+
valid: boolean;
|
|
131
|
+
error?: ArcaneError;
|
|
132
|
+
}>;
|
|
133
|
+
|
|
134
|
+
/** Apply mutations atomically. All succeed or all roll back. */
|
|
135
|
+
export function transaction<S>(
|
|
136
|
+
state: S,
|
|
137
|
+
mutations: readonly Mutation<S>[],
|
|
138
|
+
): TransactionResult<S> {
|
|
139
|
+
try {
|
|
140
|
+
let current = state;
|
|
141
|
+
for (const mutation of mutations) {
|
|
142
|
+
current = mutation.apply(current);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const diff = computeDiff(state, current);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
state: current,
|
|
149
|
+
diff,
|
|
150
|
+
effects: [],
|
|
151
|
+
valid: true,
|
|
152
|
+
};
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
155
|
+
return {
|
|
156
|
+
state,
|
|
157
|
+
diff: { entries: [] },
|
|
158
|
+
effects: [],
|
|
159
|
+
valid: false,
|
|
160
|
+
error: createError("TRANSACTION_FAILED", `Transaction failed: ${message}`, {
|
|
161
|
+
action: mutations.map((m) => m.description).join("; "),
|
|
162
|
+
reason: message,
|
|
163
|
+
suggestion: "Check that all paths exist and values are of expected types",
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Compute the diff between two state trees */
|
|
170
|
+
export function computeDiff<S>(before: S, after: S): Diff {
|
|
171
|
+
const entries: DiffEntry[] = [];
|
|
172
|
+
diffRecursive(before, after, "", entries);
|
|
173
|
+
return { entries };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Internal helpers ---
|
|
177
|
+
|
|
178
|
+
function diffRecursive(
|
|
179
|
+
before: unknown,
|
|
180
|
+
after: unknown,
|
|
181
|
+
path: string,
|
|
182
|
+
entries: DiffEntry[],
|
|
183
|
+
): void {
|
|
184
|
+
if (before === after) return;
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
typeof before !== "object" ||
|
|
188
|
+
typeof after !== "object" ||
|
|
189
|
+
before === null ||
|
|
190
|
+
after === null ||
|
|
191
|
+
Array.isArray(before) !== Array.isArray(after)
|
|
192
|
+
) {
|
|
193
|
+
entries.push({ path: path || "root", from: before, to: after });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
198
|
+
const maxLen = Math.max(before.length, after.length);
|
|
199
|
+
for (let i = 0; i < maxLen; i++) {
|
|
200
|
+
const childPath = path ? `${path}.${i}` : `${i}`;
|
|
201
|
+
if (i >= before.length) {
|
|
202
|
+
entries.push({ path: childPath, from: undefined, to: after[i] });
|
|
203
|
+
} else if (i >= after.length) {
|
|
204
|
+
entries.push({ path: childPath, from: before[i], to: undefined });
|
|
205
|
+
} else {
|
|
206
|
+
diffRecursive(before[i], after[i], childPath, entries);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (before.length !== after.length) {
|
|
210
|
+
const lengthPath = path ? `${path}.length` : "length";
|
|
211
|
+
entries.push({ path: lengthPath, from: before.length, to: after.length });
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const beforeObj = before as Record<string, unknown>;
|
|
217
|
+
const afterObj = after as Record<string, unknown>;
|
|
218
|
+
const allKeys = new Set([
|
|
219
|
+
...Object.keys(beforeObj),
|
|
220
|
+
...Object.keys(afterObj),
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
for (const key of allKeys) {
|
|
224
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
225
|
+
if (!(key in beforeObj)) {
|
|
226
|
+
entries.push({ path: childPath, from: undefined, to: afterObj[key] });
|
|
227
|
+
} else if (!(key in afterObj)) {
|
|
228
|
+
entries.push({ path: childPath, from: beforeObj[key], to: undefined });
|
|
229
|
+
} else {
|
|
230
|
+
diffRecursive(beforeObj[key], afterObj[key], childPath, entries);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getAtPath(obj: unknown, path: string): unknown {
|
|
236
|
+
const segments = path.split(".");
|
|
237
|
+
let current: unknown = obj;
|
|
238
|
+
|
|
239
|
+
for (const segment of segments) {
|
|
240
|
+
if (current === null || current === undefined) return undefined;
|
|
241
|
+
if (typeof current !== "object") return undefined;
|
|
242
|
+
current = (current as Record<string, unknown>)[segment];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return current;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function setAtPath(obj: unknown, path: string, value: unknown): unknown {
|
|
249
|
+
const segments = path.split(".");
|
|
250
|
+
|
|
251
|
+
if (segments.length === 1) {
|
|
252
|
+
if (typeof obj !== "object" || obj === null) {
|
|
253
|
+
throw new Error(`Cannot set property "${path}" on ${typeof obj}`);
|
|
254
|
+
}
|
|
255
|
+
if (Array.isArray(obj)) {
|
|
256
|
+
const index = parseInt(segments[0], 10);
|
|
257
|
+
const result = [...obj];
|
|
258
|
+
result[index] = value;
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
return { ...(obj as Record<string, unknown>), [segments[0]]: value };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const [head, ...rest] = segments;
|
|
265
|
+
const restPath = rest.join(".");
|
|
266
|
+
|
|
267
|
+
if (typeof obj !== "object" || obj === null) {
|
|
268
|
+
throw new Error(`Cannot traverse into ${typeof obj} at "${head}"`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (Array.isArray(obj)) {
|
|
272
|
+
const index = parseInt(head, 10);
|
|
273
|
+
const result = [...obj];
|
|
274
|
+
result[index] = setAtPath(result[index], restPath, value);
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const record = obj as Record<string, unknown>;
|
|
279
|
+
return { ...record, [head]: setAtPath(record[head], restPath, value) };
|
|
280
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, assert } from "../testing/harness.ts";
|
|
2
|
+
import { entityId, generateId } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
describe("entityId", () => {
|
|
5
|
+
it("creates an EntityId from a string", () => {
|
|
6
|
+
const id = entityId("goblin_1");
|
|
7
|
+
assert.equal(id, "goblin_1");
|
|
8
|
+
assert.equal(typeof id, "string");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("preserves the original string value", () => {
|
|
12
|
+
const id = entityId("player");
|
|
13
|
+
assert.equal(`${id}`, "player");
|
|
14
|
+
assert.equal(JSON.stringify(id), '"player"');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("generateId", () => {
|
|
19
|
+
it("returns a string", () => {
|
|
20
|
+
const id = generateId();
|
|
21
|
+
assert.equal(typeof id, "string");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("generates unique ids", () => {
|
|
25
|
+
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
|
|
26
|
+
assert.equal(ids.size, 100);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("generates UUID-format strings", () => {
|
|
30
|
+
const id = generateId();
|
|
31
|
+
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Branded string type for entity identification */
|
|
2
|
+
export type EntityId = string & { readonly __entityId: true };
|
|
3
|
+
|
|
4
|
+
/** Create an EntityId from a string */
|
|
5
|
+
export function entityId(id: string): EntityId {
|
|
6
|
+
return id as EntityId;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Generate a unique EntityId (uses crypto.randomUUID or counter in tests) */
|
|
10
|
+
export function generateId(): EntityId {
|
|
11
|
+
return crypto.randomUUID() as EntityId;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 2D position vector (immutable) */
|
|
15
|
+
export type Vec2 = Readonly<{ x: number; y: number }>;
|
|
16
|
+
|
|
17
|
+
type Primitive = string | number | boolean | null | undefined;
|
|
18
|
+
|
|
19
|
+
/** Deep recursive readonly — enforces immutability at the type level */
|
|
20
|
+
export type DeepReadonly<T> = T extends Primitive
|
|
21
|
+
? T
|
|
22
|
+
: T extends (infer U)[]
|
|
23
|
+
? ReadonlyArray<DeepReadonly<U>>
|
|
24
|
+
: T extends Map<infer K, infer V>
|
|
25
|
+
? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
|
|
26
|
+
: T extends Set<infer U>
|
|
27
|
+
? ReadonlySet<DeepReadonly<U>>
|
|
28
|
+
: T extends object
|
|
29
|
+
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
|
30
|
+
: T;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, it, assert } from "../testing/harness.ts";
|
|
2
|
+
import { system, rule, applyRule, getApplicableRules, extend } from "./system.ts";
|
|
3
|
+
import type { SystemDef, Rule } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
type Counter = { count: number; label: string };
|
|
6
|
+
|
|
7
|
+
describe("system()", () => {
|
|
8
|
+
it("creates a named system with rules", () => {
|
|
9
|
+
const r = rule<Counter>("inc").then((s) => ({ ...s, count: s.count + 1 }));
|
|
10
|
+
const sys = system("counter", [r]);
|
|
11
|
+
assert.equal(sys.name, "counter");
|
|
12
|
+
assert.equal(sys.rules.length, 1);
|
|
13
|
+
assert.equal(sys.rules[0].name, "inc");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("creates an empty system", () => {
|
|
17
|
+
const sys = system<Counter>("empty", []);
|
|
18
|
+
assert.equal(sys.rules.length, 0);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("rule()", () => {
|
|
23
|
+
it("creates a rule with no conditions", () => {
|
|
24
|
+
const r = rule<Counter>("inc").then((s) => ({ ...s, count: s.count + 1 }));
|
|
25
|
+
assert.equal(r.name, "inc");
|
|
26
|
+
assert.equal(r.conditions.length, 0);
|
|
27
|
+
assert.equal(r.actions.length, 1);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("creates a rule with conditions and actions", () => {
|
|
31
|
+
const r = rule<Counter>("inc-if-low")
|
|
32
|
+
.when((s) => s.count < 10)
|
|
33
|
+
.then((s) => ({ ...s, count: s.count + 1 }));
|
|
34
|
+
assert.equal(r.conditions.length, 1);
|
|
35
|
+
assert.equal(r.actions.length, 1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("chains multiple actions", () => {
|
|
39
|
+
const r = rule<Counter>("double-inc")
|
|
40
|
+
.then(
|
|
41
|
+
(s) => ({ ...s, count: s.count + 1 }),
|
|
42
|
+
(s) => ({ ...s, count: s.count * 2 }),
|
|
43
|
+
);
|
|
44
|
+
assert.equal(r.actions.length, 2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("chains multiple conditions", () => {
|
|
48
|
+
const r = rule<Counter>("guarded")
|
|
49
|
+
.when(
|
|
50
|
+
(s) => s.count > 0,
|
|
51
|
+
(s) => s.count < 100,
|
|
52
|
+
)
|
|
53
|
+
.then((s) => s);
|
|
54
|
+
assert.equal(r.conditions.length, 2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("creates a replacement rule", () => {
|
|
58
|
+
const r = rule<Counter>("new-inc")
|
|
59
|
+
.replaces("inc")
|
|
60
|
+
.then((s) => ({ ...s, count: s.count + 10 }));
|
|
61
|
+
assert.equal(r.replaces, "inc");
|
|
62
|
+
assert.equal(r.name, "new-inc");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("replaces with conditions", () => {
|
|
66
|
+
const r = rule<Counter>("guarded-inc")
|
|
67
|
+
.replaces("inc")
|
|
68
|
+
.when((s) => s.count < 5)
|
|
69
|
+
.then((s) => ({ ...s, count: s.count + 1 }));
|
|
70
|
+
assert.equal(r.replaces, "inc");
|
|
71
|
+
assert.equal(r.conditions.length, 1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("applyRule()", () => {
|
|
76
|
+
const inc = rule<Counter>("inc").then((s) => ({ ...s, count: s.count + 1 }));
|
|
77
|
+
const guarded = rule<Counter>("guarded")
|
|
78
|
+
.when((s) => s.count < 3)
|
|
79
|
+
.then((s) => ({ ...s, count: s.count + 1 }));
|
|
80
|
+
const sys = system("counter", [inc, guarded]);
|
|
81
|
+
|
|
82
|
+
it("applies an unconditional rule", () => {
|
|
83
|
+
const result = applyRule(sys, "inc", { count: 0, label: "x" });
|
|
84
|
+
assert.ok(result.ok);
|
|
85
|
+
assert.equal(result.state.count, 1);
|
|
86
|
+
assert.equal(result.ruleName, "inc");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("applies a conditional rule when met", () => {
|
|
90
|
+
const result = applyRule(sys, "guarded", { count: 1, label: "x" });
|
|
91
|
+
assert.ok(result.ok);
|
|
92
|
+
assert.equal(result.state.count, 2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects when condition not met", () => {
|
|
96
|
+
const result = applyRule(sys, "guarded", { count: 3, label: "x" });
|
|
97
|
+
assert.equal(result.ok, false);
|
|
98
|
+
assert.equal(result.state.count, 3);
|
|
99
|
+
assert.ok(result.error);
|
|
100
|
+
assert.equal(result.error!.code, "CONDITION_FAILED");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns error for unknown rule", () => {
|
|
104
|
+
const result = applyRule(sys, "nope", { count: 0, label: "x" });
|
|
105
|
+
assert.equal(result.ok, false);
|
|
106
|
+
assert.equal(result.error!.code, "UNKNOWN_RULE");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("chains multiple actions in order", () => {
|
|
110
|
+
const r = rule<Counter>("add-then-double")
|
|
111
|
+
.then(
|
|
112
|
+
(s) => ({ ...s, count: s.count + 3 }),
|
|
113
|
+
(s) => ({ ...s, count: s.count * 2 }),
|
|
114
|
+
);
|
|
115
|
+
const s = system("math", [r]);
|
|
116
|
+
const result = applyRule(s, "add-then-double", { count: 1, label: "x" });
|
|
117
|
+
assert.ok(result.ok);
|
|
118
|
+
assert.equal(result.state.count, 8); // (1+3)*2 = 8
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("passes args to conditions and actions", () => {
|
|
122
|
+
const r = rule<Counter>("add-n")
|
|
123
|
+
.when((_s, args) => (args.n as number) > 0)
|
|
124
|
+
.then((s, args) => ({ ...s, count: s.count + (args.n as number) }));
|
|
125
|
+
const s = system("args-test", [r]);
|
|
126
|
+
const result = applyRule(s, "add-n", { count: 5, label: "x" }, { n: 3 });
|
|
127
|
+
assert.ok(result.ok);
|
|
128
|
+
assert.equal(result.state.count, 8);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("preserves original state on failure", () => {
|
|
132
|
+
const original = { count: 5, label: "x" };
|
|
133
|
+
const result = applyRule(sys, "guarded", original);
|
|
134
|
+
assert.equal(result.state, original);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("getApplicableRules()", () => {
|
|
139
|
+
const always = rule<Counter>("always").then((s) => s);
|
|
140
|
+
const lowOnly = rule<Counter>("low-only")
|
|
141
|
+
.when((s) => s.count < 3)
|
|
142
|
+
.then((s) => s);
|
|
143
|
+
const highOnly = rule<Counter>("high-only")
|
|
144
|
+
.when((s) => s.count >= 3)
|
|
145
|
+
.then((s) => s);
|
|
146
|
+
const sys = system("multi", [always, lowOnly, highOnly]);
|
|
147
|
+
|
|
148
|
+
it("returns all applicable rules for low count", () => {
|
|
149
|
+
const names = getApplicableRules(sys, { count: 1, label: "x" });
|
|
150
|
+
assert.deepEqual(names, ["always", "low-only"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns all applicable rules for high count", () => {
|
|
154
|
+
const names = getApplicableRules(sys, { count: 5, label: "x" });
|
|
155
|
+
assert.deepEqual(names, ["always", "high-only"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("passes args to conditions", () => {
|
|
159
|
+
const argRule = rule<Counter>("needs-flag")
|
|
160
|
+
.when((_s, args) => args.enabled === true)
|
|
161
|
+
.then((s) => s);
|
|
162
|
+
const s = system("arg-sys", [argRule]);
|
|
163
|
+
assert.deepEqual(getApplicableRules(s, { count: 0, label: "x" }, { enabled: true }), ["needs-flag"]);
|
|
164
|
+
assert.deepEqual(getApplicableRules(s, { count: 0, label: "x" }, { enabled: false }), []);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("extend()", () => {
|
|
169
|
+
const inc = rule<Counter>("inc").then((s) => ({ ...s, count: s.count + 1 }));
|
|
170
|
+
const dec = rule<Counter>("dec").then((s) => ({ ...s, count: s.count - 1 }));
|
|
171
|
+
const base = system("counter", [inc, dec]);
|
|
172
|
+
|
|
173
|
+
it("adds new rules", () => {
|
|
174
|
+
const doubleRule = rule<Counter>("double").then((s) => ({ ...s, count: s.count * 2 }));
|
|
175
|
+
const ext = extend(base, { rules: [doubleRule] });
|
|
176
|
+
assert.equal(ext.rules.length, 3);
|
|
177
|
+
assert.equal(ext.rules[2].name, "double");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("replaces rules by name", () => {
|
|
181
|
+
const bigInc = rule<Counter>("big-inc")
|
|
182
|
+
.replaces("inc")
|
|
183
|
+
.then((s) => ({ ...s, count: s.count + 10 }));
|
|
184
|
+
const ext = extend(base, { rules: [bigInc] });
|
|
185
|
+
assert.equal(ext.rules.length, 2);
|
|
186
|
+
// First rule is replaced
|
|
187
|
+
const result = applyRule(ext, "big-inc", { count: 0, label: "x" });
|
|
188
|
+
assert.ok(result.ok);
|
|
189
|
+
assert.equal(result.state.count, 10);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("removes rules by name", () => {
|
|
193
|
+
const ext = extend(base, { remove: ["dec"] });
|
|
194
|
+
assert.equal(ext.rules.length, 1);
|
|
195
|
+
assert.equal(ext.rules[0].name, "inc");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("combines add, replace, and remove", () => {
|
|
199
|
+
const bigInc = rule<Counter>("big-inc")
|
|
200
|
+
.replaces("inc")
|
|
201
|
+
.then((s) => ({ ...s, count: s.count + 10 }));
|
|
202
|
+
const reset = rule<Counter>("reset").then((s) => ({ ...s, count: 0 }));
|
|
203
|
+
const ext = extend(base, {
|
|
204
|
+
rules: [bigInc, reset],
|
|
205
|
+
remove: ["dec"],
|
|
206
|
+
});
|
|
207
|
+
// inc replaced by big-inc, dec removed, reset added
|
|
208
|
+
assert.equal(ext.rules.length, 2);
|
|
209
|
+
assert.equal(ext.rules[0].name, "big-inc");
|
|
210
|
+
assert.equal(ext.rules[1].name, "reset");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("preserves system name", () => {
|
|
214
|
+
const ext = extend(base, { rules: [] });
|
|
215
|
+
assert.equal(ext.name, "counter");
|
|
216
|
+
});
|
|
217
|
+
});
|