@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.
Files changed (57) hide show
  1. package/README.md +38 -0
  2. package/index.ts +19 -0
  3. package/package.json +53 -0
  4. package/src/agent/agent.test.ts +384 -0
  5. package/src/agent/describe.ts +72 -0
  6. package/src/agent/index.ts +20 -0
  7. package/src/agent/protocol.ts +125 -0
  8. package/src/agent/types.ts +73 -0
  9. package/src/pathfinding/astar.test.ts +208 -0
  10. package/src/pathfinding/astar.ts +193 -0
  11. package/src/pathfinding/index.ts +2 -0
  12. package/src/pathfinding/types.ts +21 -0
  13. package/src/physics/aabb.ts +54 -0
  14. package/src/physics/index.ts +2 -0
  15. package/src/rendering/animation.test.ts +119 -0
  16. package/src/rendering/animation.ts +132 -0
  17. package/src/rendering/audio.test.ts +33 -0
  18. package/src/rendering/audio.ts +70 -0
  19. package/src/rendering/camera.ts +35 -0
  20. package/src/rendering/index.ts +56 -0
  21. package/src/rendering/input.test.ts +70 -0
  22. package/src/rendering/input.ts +82 -0
  23. package/src/rendering/lighting.ts +38 -0
  24. package/src/rendering/loop.ts +21 -0
  25. package/src/rendering/sprites.ts +60 -0
  26. package/src/rendering/text.test.ts +91 -0
  27. package/src/rendering/text.ts +184 -0
  28. package/src/rendering/texture.ts +31 -0
  29. package/src/rendering/tilemap.ts +46 -0
  30. package/src/rendering/types.ts +54 -0
  31. package/src/rendering/validate.ts +132 -0
  32. package/src/state/error.test.ts +45 -0
  33. package/src/state/error.ts +20 -0
  34. package/src/state/index.ts +70 -0
  35. package/src/state/observe.test.ts +173 -0
  36. package/src/state/observe.ts +110 -0
  37. package/src/state/prng.test.ts +221 -0
  38. package/src/state/prng.ts +162 -0
  39. package/src/state/query.test.ts +208 -0
  40. package/src/state/query.ts +144 -0
  41. package/src/state/store.test.ts +211 -0
  42. package/src/state/store.ts +109 -0
  43. package/src/state/transaction.test.ts +235 -0
  44. package/src/state/transaction.ts +280 -0
  45. package/src/state/types.test.ts +33 -0
  46. package/src/state/types.ts +30 -0
  47. package/src/systems/index.ts +2 -0
  48. package/src/systems/system.test.ts +217 -0
  49. package/src/systems/system.ts +150 -0
  50. package/src/systems/types.ts +35 -0
  51. package/src/testing/harness.ts +271 -0
  52. package/src/testing/mock-renderer.test.ts +93 -0
  53. package/src/testing/mock-renderer.ts +178 -0
  54. package/src/ui/index.ts +3 -0
  55. package/src/ui/primitives.test.ts +105 -0
  56. package/src/ui/primitives.ts +260 -0
  57. package/src/ui/types.ts +57 -0
@@ -0,0 +1,70 @@
1
+ // Foundational types
2
+ export type { EntityId, Vec2, DeepReadonly } from "./types.ts";
3
+ export { entityId, generateId } from "./types.ts";
4
+
5
+ // Error types
6
+ export type { ArcaneError } from "./error.ts";
7
+ export { createError } from "./error.ts";
8
+
9
+ // PRNG
10
+ export type { PRNGState, DiceNotation, DiceSpec } from "./prng.ts";
11
+ export {
12
+ seed,
13
+ parseDice,
14
+ rollDice,
15
+ randomInt,
16
+ randomFloat,
17
+ randomPick,
18
+ shuffle,
19
+ } from "./prng.ts";
20
+
21
+ // Transactions
22
+ export type {
23
+ Mutation,
24
+ DiffEntry,
25
+ Diff,
26
+ Effect,
27
+ TransactionResult,
28
+ } from "./transaction.ts";
29
+ export {
30
+ set,
31
+ update,
32
+ push,
33
+ removeWhere,
34
+ removeKey,
35
+ transaction,
36
+ computeDiff,
37
+ } from "./transaction.ts";
38
+
39
+ // Queries
40
+ export type { Predicate } from "./query.ts";
41
+ export {
42
+ query,
43
+ get,
44
+ has,
45
+ lt,
46
+ gt,
47
+ lte,
48
+ gte,
49
+ eq,
50
+ neq,
51
+ oneOf,
52
+ within,
53
+ allOf,
54
+ anyOf,
55
+ not,
56
+ } from "./query.ts";
57
+
58
+ // Observers
59
+ export type {
60
+ ObserverCallback,
61
+ ObserverContext,
62
+ Unsubscribe,
63
+ PathPattern,
64
+ ObserverRegistry,
65
+ } from "./observe.ts";
66
+ export { createObserverRegistry } from "./observe.ts";
67
+
68
+ // Store
69
+ export type { GameStore, TransactionRecord } from "./store.ts";
70
+ export { createStore } from "./store.ts";
@@ -0,0 +1,173 @@
1
+ import { describe, it, assert } from "../testing/harness.ts";
2
+ import { createObserverRegistry } from "./observe.ts";
3
+ import { computeDiff } from "./transaction.ts";
4
+ import type { Diff } from "./transaction.ts";
5
+
6
+ type State = {
7
+ party: { id: string; hp: number }[];
8
+ turn: number;
9
+ };
10
+
11
+ const before: State = {
12
+ party: [
13
+ { id: "alice", hp: 20 },
14
+ { id: "bob", hp: 15 },
15
+ ],
16
+ turn: 1,
17
+ };
18
+
19
+ describe("createObserverRegistry", () => {
20
+ it("creates a registry with observe, notify, and clear", () => {
21
+ const reg = createObserverRegistry<State>();
22
+ assert.equal(typeof reg.observe, "function");
23
+ assert.equal(typeof reg.notify, "function");
24
+ assert.equal(typeof reg.clear, "function");
25
+ });
26
+ });
27
+
28
+ describe("observe + notify", () => {
29
+ it("fires callback when path matches a diff entry", () => {
30
+ const reg = createObserverRegistry<State>();
31
+ const calls: { newVal: unknown; oldVal: unknown; path: string }[] = [];
32
+
33
+ reg.observe("turn", (newVal, oldVal, ctx) => {
34
+ calls.push({ newVal, oldVal, path: ctx.path });
35
+ });
36
+
37
+ const after = { ...before, turn: 2 };
38
+ const diff = computeDiff(before, after);
39
+ reg.notify(before, after, diff);
40
+
41
+ assert.equal(calls.length, 1);
42
+ assert.equal(calls[0].newVal, 2);
43
+ assert.equal(calls[0].oldVal, 1);
44
+ assert.equal(calls[0].path, "turn");
45
+ });
46
+
47
+ it("does not fire when path does not match", () => {
48
+ const reg = createObserverRegistry<State>();
49
+ let fired = false;
50
+
51
+ reg.observe("turn", () => {
52
+ fired = true;
53
+ });
54
+
55
+ // Change party, not turn
56
+ const after = {
57
+ ...before,
58
+ party: [{ id: "alice", hp: 18 }, before.party[1]],
59
+ };
60
+ const diff = computeDiff(before, after);
61
+ reg.notify(before, after, diff);
62
+
63
+ assert.equal(fired, false);
64
+ });
65
+
66
+ it("supports wildcard patterns", () => {
67
+ const reg = createObserverRegistry<State>();
68
+ const changedPaths: string[] = [];
69
+
70
+ reg.observe("party.*.hp", (_newVal, _oldVal, ctx) => {
71
+ changedPaths.push(ctx.path);
72
+ });
73
+
74
+ const after: State = {
75
+ ...before,
76
+ party: [
77
+ { id: "alice", hp: 18 },
78
+ { id: "bob", hp: 10 },
79
+ ],
80
+ };
81
+ const diff = computeDiff(before, after);
82
+ reg.notify(before, after, diff);
83
+
84
+ assert.deepEqual(changedPaths.sort(), ["party.0.hp", "party.1.hp"]);
85
+ });
86
+
87
+ it("wildcard matches only at the right depth", () => {
88
+ const reg = createObserverRegistry<State>();
89
+ let fired = false;
90
+
91
+ // This pattern expects 3 segments: party.*.hp
92
+ reg.observe("party.*.hp", () => {
93
+ fired = true;
94
+ });
95
+
96
+ // Change at "turn" (1 segment) — should not match
97
+ const after = { ...before, turn: 5 };
98
+ const diff = computeDiff(before, after);
99
+ reg.notify(before, after, diff);
100
+
101
+ assert.equal(fired, false);
102
+ });
103
+
104
+ it("supports multiple observers on the same pattern", () => {
105
+ const reg = createObserverRegistry<State>();
106
+ let count = 0;
107
+
108
+ reg.observe("turn", () => count++);
109
+ reg.observe("turn", () => count++);
110
+
111
+ const after = { ...before, turn: 2 };
112
+ const diff = computeDiff(before, after);
113
+ reg.notify(before, after, diff);
114
+
115
+ assert.equal(count, 2);
116
+ });
117
+
118
+ it("provides the full diff in context", () => {
119
+ const reg = createObserverRegistry<State>();
120
+ let receivedDiff: Diff | undefined;
121
+
122
+ reg.observe("turn", (_n, _o, ctx) => {
123
+ receivedDiff = ctx.diff;
124
+ });
125
+
126
+ const after = { ...before, turn: 2 };
127
+ const diff = computeDiff(before, after);
128
+ reg.notify(before, after, diff);
129
+
130
+ assert.notEqual(receivedDiff, undefined);
131
+ assert.equal(receivedDiff!.entries.length, 1);
132
+ });
133
+ });
134
+
135
+ describe("unsubscribe", () => {
136
+ it("stops receiving notifications after unsubscribe", () => {
137
+ const reg = createObserverRegistry<State>();
138
+ let count = 0;
139
+
140
+ const unsub = reg.observe("turn", () => count++);
141
+
142
+ const after1 = { ...before, turn: 2 };
143
+ reg.notify(before, after1, computeDiff(before, after1));
144
+ assert.equal(count, 1);
145
+
146
+ unsub();
147
+
148
+ const after2 = { ...after1, turn: 3 };
149
+ reg.notify(after1, after2, computeDiff(after1, after2));
150
+ assert.equal(count, 1); // no change
151
+ });
152
+ });
153
+
154
+ describe("clear", () => {
155
+ it("removes all observers", () => {
156
+ const reg = createObserverRegistry<State>();
157
+ let count = 0;
158
+
159
+ reg.observe("turn", () => count++);
160
+ reg.observe("party.*.hp", () => count++);
161
+
162
+ reg.clear();
163
+
164
+ const after = {
165
+ ...before,
166
+ turn: 5,
167
+ party: [{ id: "alice", hp: 0 }, before.party[1]],
168
+ };
169
+ reg.notify(before, after, computeDiff(before, after));
170
+
171
+ assert.equal(count, 0);
172
+ });
173
+ });
@@ -0,0 +1,110 @@
1
+ import type { Diff } from "./transaction.ts";
2
+
3
+ /** Observer callback — receives new value, old value, and context */
4
+ export type ObserverCallback<T = unknown> = (
5
+ newValue: T,
6
+ oldValue: T,
7
+ context: ObserverContext,
8
+ ) => void;
9
+
10
+ /** Context provided to observer callbacks */
11
+ export type ObserverContext = Readonly<{
12
+ path: string;
13
+ diff: Diff;
14
+ }>;
15
+
16
+ /** Unsubscribe function */
17
+ export type Unsubscribe = () => void;
18
+
19
+ /** Pattern for path matching (supports * wildcards) */
20
+ export type PathPattern = string;
21
+
22
+ /** Observer registry — manages subscriptions and dispatches notifications */
23
+ export type ObserverRegistry<S> = Readonly<{
24
+ /** Subscribe to changes at a path pattern */
25
+ observe: <T = unknown>(
26
+ pattern: PathPattern,
27
+ callback: ObserverCallback<T>,
28
+ ) => Unsubscribe;
29
+
30
+ /** Notify all matching observers after a transaction commits */
31
+ notify: (oldState: S, newState: S, diff: Diff) => void;
32
+
33
+ /** Remove all observers */
34
+ clear: () => void;
35
+ }>;
36
+
37
+ type Subscription = {
38
+ pattern: PathPattern;
39
+ callback: ObserverCallback;
40
+ };
41
+
42
+ /** Create a new observer registry */
43
+ export function createObserverRegistry<S>(): ObserverRegistry<S> {
44
+ const subscriptions = new Set<Subscription>();
45
+
46
+ return {
47
+ observe<T = unknown>(
48
+ pattern: PathPattern,
49
+ callback: ObserverCallback<T>,
50
+ ): Unsubscribe {
51
+ const sub: Subscription = {
52
+ pattern,
53
+ callback: callback as ObserverCallback,
54
+ };
55
+ subscriptions.add(sub);
56
+ return () => {
57
+ subscriptions.delete(sub);
58
+ };
59
+ },
60
+
61
+ notify(oldState: S, newState: S, diff: Diff): void {
62
+ for (const entry of diff.entries) {
63
+ for (const sub of subscriptions) {
64
+ if (pathMatches(sub.pattern, entry.path)) {
65
+ sub.callback(
66
+ getByPath(newState, entry.path),
67
+ getByPath(oldState, entry.path),
68
+ { path: entry.path, diff },
69
+ );
70
+ }
71
+ }
72
+ }
73
+ },
74
+
75
+ clear(): void {
76
+ subscriptions.clear();
77
+ },
78
+ };
79
+ }
80
+
81
+ // --- Internal: path pattern matching ---
82
+
83
+ /** Check if a concrete path matches a pattern with * wildcards */
84
+ function pathMatches(pattern: string, path: string): boolean {
85
+ const patternParts = pattern.split(".");
86
+ const pathParts = path.split(".");
87
+
88
+ if (patternParts.length !== pathParts.length) return false;
89
+
90
+ for (let i = 0; i < patternParts.length; i++) {
91
+ if (patternParts[i] === "*") continue;
92
+ if (patternParts[i] !== pathParts[i]) return false;
93
+ }
94
+
95
+ return true;
96
+ }
97
+
98
+ /** Traverse an object by dot-separated path */
99
+ function getByPath(obj: unknown, path: string): unknown {
100
+ const segments = path.split(".");
101
+ let current: unknown = obj;
102
+
103
+ for (const segment of segments) {
104
+ if (current === null || current === undefined) return undefined;
105
+ if (typeof current !== "object") return undefined;
106
+ current = (current as Record<string, unknown>)[segment];
107
+ }
108
+
109
+ return current;
110
+ }
@@ -0,0 +1,221 @@
1
+ import { describe, it, assert } from "../testing/harness.ts";
2
+ import {
3
+ seed,
4
+ parseDice,
5
+ rollDice,
6
+ randomInt,
7
+ randomFloat,
8
+ randomPick,
9
+ shuffle,
10
+ } from "./prng.ts";
11
+
12
+ describe("seed", () => {
13
+ it("creates a PRNGState from a number", () => {
14
+ const rng = seed(42);
15
+ assert.equal(rng.__brand, "PRNGState");
16
+ assert.equal(rng.seed, 42);
17
+ assert.equal(typeof rng.s0, "number");
18
+ assert.equal(typeof rng.s1, "number");
19
+ assert.equal(typeof rng.s2, "number");
20
+ assert.equal(typeof rng.s3, "number");
21
+ });
22
+
23
+ it("produces different internal state for different seeds", () => {
24
+ const a = seed(1);
25
+ const b = seed(2);
26
+ assert.notDeepEqual(
27
+ [a.s0, a.s1, a.s2, a.s3],
28
+ [b.s0, b.s1, b.s2, b.s3],
29
+ );
30
+ });
31
+ });
32
+
33
+ describe("determinism", () => {
34
+ it("same seed produces same sequence of floats", () => {
35
+ let rng1 = seed(42);
36
+ let rng2 = seed(42);
37
+ const seq1: number[] = [];
38
+ const seq2: number[] = [];
39
+
40
+ for (let i = 0; i < 20; i++) {
41
+ const [v1, next1] = randomFloat(rng1);
42
+ const [v2, next2] = randomFloat(rng2);
43
+ seq1.push(v1);
44
+ seq2.push(v2);
45
+ rng1 = next1;
46
+ rng2 = next2;
47
+ }
48
+
49
+ assert.deepEqual(seq1, seq2);
50
+ });
51
+
52
+ it("same seed produces same dice rolls", () => {
53
+ let rng1 = seed(123);
54
+ let rng2 = seed(123);
55
+
56
+ for (let i = 0; i < 10; i++) {
57
+ const [roll1, next1] = rollDice(rng1, "2d6+3");
58
+ const [roll2, next2] = rollDice(rng2, "2d6+3");
59
+ assert.equal(roll1, roll2);
60
+ rng1 = next1;
61
+ rng2 = next2;
62
+ }
63
+ });
64
+
65
+ it("different seeds produce different sequences", () => {
66
+ let rng1 = seed(1);
67
+ let rng2 = seed(2);
68
+ const seq1: number[] = [];
69
+ const seq2: number[] = [];
70
+
71
+ for (let i = 0; i < 10; i++) {
72
+ const [v1, next1] = randomFloat(rng1);
73
+ const [v2, next2] = randomFloat(rng2);
74
+ seq1.push(v1);
75
+ seq2.push(v2);
76
+ rng1 = next1;
77
+ rng2 = next2;
78
+ }
79
+
80
+ assert.notDeepEqual(seq1, seq2);
81
+ });
82
+ });
83
+
84
+ describe("parseDice", () => {
85
+ it("parses basic notation", () => {
86
+ assert.deepEqual(parseDice("2d6"), { count: 2, sides: 6, modifier: 0 });
87
+ });
88
+
89
+ it("parses positive modifier", () => {
90
+ assert.deepEqual(parseDice("1d20+5"), { count: 1, sides: 20, modifier: 5 });
91
+ });
92
+
93
+ it("parses negative modifier", () => {
94
+ assert.deepEqual(parseDice("3d8-2"), { count: 3, sides: 8, modifier: -2 });
95
+ });
96
+
97
+ it("throws on invalid notation", () => {
98
+ assert.throws(() => parseDice("bad"), /Invalid dice notation/);
99
+ assert.throws(() => parseDice("d6"), /Invalid dice notation/);
100
+ assert.throws(() => parseDice("2d"), /Invalid dice notation/);
101
+ });
102
+ });
103
+
104
+ describe("rollDice", () => {
105
+ it("returns a result within expected range", () => {
106
+ let rng = seed(42);
107
+ for (let i = 0; i < 100; i++) {
108
+ const [result, next] = rollDice(rng, "2d6+3");
109
+ assert.ok(result >= 5, `2d6+3 minimum is 5, got ${result}`);
110
+ assert.ok(result <= 15, `2d6+3 maximum is 15, got ${result}`);
111
+ rng = next;
112
+ }
113
+ });
114
+
115
+ it("accepts DiceSpec directly", () => {
116
+ const spec = parseDice("1d20");
117
+ let rng = seed(99);
118
+ const [result, _] = rollDice(rng, spec);
119
+ assert.ok(result >= 1 && result <= 20);
120
+ });
121
+
122
+ it("accepts string notation", () => {
123
+ const rng = seed(99);
124
+ const [result, _] = rollDice(rng, "1d20");
125
+ assert.ok(result >= 1 && result <= 20);
126
+ });
127
+ });
128
+
129
+ describe("randomInt", () => {
130
+ it("returns values within [min, max] inclusive", () => {
131
+ let rng = seed(42);
132
+ for (let i = 0; i < 200; i++) {
133
+ const [value, next] = randomInt(rng, 1, 6);
134
+ assert.ok(value >= 1, `Expected >= 1, got ${value}`);
135
+ assert.ok(value <= 6, `Expected <= 6, got ${value}`);
136
+ rng = next;
137
+ }
138
+ });
139
+
140
+ it("covers the full range", () => {
141
+ let rng = seed(42);
142
+ const seen = new Set<number>();
143
+ for (let i = 0; i < 500; i++) {
144
+ const [value, next] = randomInt(rng, 1, 6);
145
+ seen.add(value);
146
+ rng = next;
147
+ }
148
+ assert.deepEqual([...seen].sort(), [1, 2, 3, 4, 5, 6]);
149
+ });
150
+ });
151
+
152
+ describe("randomFloat", () => {
153
+ it("returns values in [0, 1)", () => {
154
+ let rng = seed(42);
155
+ for (let i = 0; i < 200; i++) {
156
+ const [value, next] = randomFloat(rng);
157
+ assert.ok(value >= 0, `Expected >= 0, got ${value}`);
158
+ assert.ok(value < 1, `Expected < 1, got ${value}`);
159
+ rng = next;
160
+ }
161
+ });
162
+ });
163
+
164
+ describe("randomPick", () => {
165
+ it("picks an element from the array", () => {
166
+ const items = ["sword", "shield", "potion"];
167
+ let rng = seed(42);
168
+ for (let i = 0; i < 50; i++) {
169
+ const [item, next] = randomPick(rng, items);
170
+ assert.ok(items.includes(item), `Picked unknown item: ${item}`);
171
+ rng = next;
172
+ }
173
+ });
174
+
175
+ it("covers all elements given enough picks", () => {
176
+ const items = ["a", "b", "c"];
177
+ let rng = seed(42);
178
+ const seen = new Set<string>();
179
+ for (let i = 0; i < 100; i++) {
180
+ const [item, next] = randomPick(rng, items);
181
+ seen.add(item);
182
+ rng = next;
183
+ }
184
+ assert.equal(seen.size, 3);
185
+ });
186
+ });
187
+
188
+ describe("shuffle", () => {
189
+ it("returns an array of the same length", () => {
190
+ const items = [1, 2, 3, 4, 5];
191
+ const [result, _] = shuffle(seed(42), items);
192
+ assert.equal(result.length, items.length);
193
+ });
194
+
195
+ it("contains the same elements", () => {
196
+ const items = [1, 2, 3, 4, 5];
197
+ const [result, _] = shuffle(seed(42), items);
198
+ assert.deepEqual([...result].sort(), [...items].sort());
199
+ });
200
+
201
+ it("does not mutate the original array", () => {
202
+ const items = [1, 2, 3, 4, 5];
203
+ const copy = [...items];
204
+ shuffle(seed(42), items);
205
+ assert.deepEqual(items, copy);
206
+ });
207
+
208
+ it("is deterministic", () => {
209
+ const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
210
+ const [r1, _] = shuffle(seed(42), items);
211
+ const [r2, __] = shuffle(seed(42), items);
212
+ assert.deepEqual(r1, r2);
213
+ });
214
+
215
+ it("actually shuffles (not identity)", () => {
216
+ const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
217
+ const [result, _] = shuffle(seed(42), items);
218
+ // With 10 elements, the probability of no change is astronomically low
219
+ assert.notDeepEqual(result, items);
220
+ });
221
+ });