@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,162 @@
1
+ /** Opaque PRNG state — serializable, deterministic (xoshiro128**) */
2
+ export type PRNGState = Readonly<{
3
+ readonly __brand: "PRNGState";
4
+ seed: number;
5
+ s0: number;
6
+ s1: number;
7
+ s2: number;
8
+ s3: number;
9
+ }>;
10
+
11
+ /** Create a seeded PRNG */
12
+ export function seed(n: number): PRNGState {
13
+ // Initialize state from seed using splitmix32
14
+ let s = n | 0;
15
+ const s0 = splitmix32(s);
16
+ s = s0.state;
17
+ const s1 = splitmix32(s);
18
+ s = s1.state;
19
+ const s2 = splitmix32(s);
20
+ s = s2.state;
21
+ const s3 = splitmix32(s);
22
+
23
+ return {
24
+ __brand: "PRNGState" as const,
25
+ seed: n,
26
+ s0: s0.value,
27
+ s1: s1.value,
28
+ s2: s2.value,
29
+ s3: s3.value,
30
+ };
31
+ }
32
+
33
+ /** Dice notation: "2d6+3" */
34
+ export type DiceNotation = string & { readonly __dice: true };
35
+
36
+ /** Parsed dice specification */
37
+ export type DiceSpec = Readonly<{
38
+ count: number;
39
+ sides: number;
40
+ modifier: number;
41
+ }>;
42
+
43
+ /** Parse dice notation string into a spec */
44
+ export function parseDice(notation: string): DiceSpec {
45
+ const match = notation.match(/^(\d+)d(\d+)([+-]\d+)?$/);
46
+ if (!match) {
47
+ throw new Error(
48
+ `Invalid dice notation: "${notation}". Expected format: NdS or NdS+M (e.g. "2d6+3")`,
49
+ );
50
+ }
51
+ return {
52
+ count: parseInt(match[1], 10),
53
+ sides: parseInt(match[2], 10),
54
+ modifier: match[3] ? parseInt(match[3], 10) : 0,
55
+ };
56
+ }
57
+
58
+ /** Roll dice. Returns [result, newRngState] */
59
+ export function rollDice(
60
+ rng: PRNGState,
61
+ spec: DiceSpec | string,
62
+ ): [number, PRNGState] {
63
+ const parsed = typeof spec === "string" ? parseDice(spec) : spec;
64
+ let total = parsed.modifier;
65
+ let current = rng;
66
+
67
+ for (let i = 0; i < parsed.count; i++) {
68
+ const [roll, next] = randomInt(current, 1, parsed.sides);
69
+ total += roll;
70
+ current = next;
71
+ }
72
+
73
+ return [total, current];
74
+ }
75
+
76
+ /** Random integer in [min, max] inclusive */
77
+ export function randomInt(
78
+ rng: PRNGState,
79
+ min: number,
80
+ max: number,
81
+ ): [number, PRNGState] {
82
+ const [f, next] = randomFloat(rng);
83
+ const range = max - min + 1;
84
+ const value = min + Math.floor(f * range);
85
+ return [value, next];
86
+ }
87
+
88
+ /** Random float in [0, 1) */
89
+ export function randomFloat(rng: PRNGState): [number, PRNGState] {
90
+ const next = advance(rng);
91
+ const result = (xoshiro128ss(rng) >>> 0) / 4294967296;
92
+ return [result, next];
93
+ }
94
+
95
+ /** Pick one random element */
96
+ export function randomPick<T>(
97
+ rng: PRNGState,
98
+ items: readonly T[],
99
+ ): [T, PRNGState] {
100
+ const [index, next] = randomInt(rng, 0, items.length - 1);
101
+ return [items[index], next];
102
+ }
103
+
104
+ /** Shuffle array (Fisher-Yates). Returns new array. */
105
+ export function shuffle<T>(
106
+ rng: PRNGState,
107
+ items: readonly T[],
108
+ ): [readonly T[], PRNGState] {
109
+ const result = [...items];
110
+ let current = rng;
111
+
112
+ for (let i = result.length - 1; i > 0; i--) {
113
+ const [j, next] = randomInt(current, 0, i);
114
+ current = next;
115
+ const temp = result[i];
116
+ result[i] = result[j];
117
+ result[j] = temp;
118
+ }
119
+
120
+ return [result, current];
121
+ }
122
+
123
+ // --- Internal: xoshiro128** ---
124
+
125
+ function xoshiro128ss(state: PRNGState): number {
126
+ const result = Math.imul(rotl(Math.imul(state.s1, 5), 7), 9);
127
+ return result | 0;
128
+ }
129
+
130
+ function advance(state: PRNGState): PRNGState {
131
+ const t = state.s1 << 9;
132
+
133
+ let s2 = state.s2 ^ state.s0;
134
+ let s3 = state.s3 ^ state.s1;
135
+ const s1 = state.s1 ^ s2;
136
+ const s0 = state.s0 ^ s3;
137
+
138
+ s2 = s2 ^ t;
139
+ s3 = rotl(s3, 11);
140
+
141
+ return {
142
+ __brand: "PRNGState" as const,
143
+ seed: state.seed,
144
+ s0,
145
+ s1,
146
+ s2,
147
+ s3,
148
+ };
149
+ }
150
+
151
+ function rotl(x: number, k: number): number {
152
+ return (x << k) | (x >>> (32 - k));
153
+ }
154
+
155
+ function splitmix32(state: number): { value: number; state: number } {
156
+ state = (state + 0x9e3779b9) | 0;
157
+ let z = state;
158
+ z = Math.imul(z ^ (z >>> 16), 0x85ebca6b);
159
+ z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35);
160
+ z = z ^ (z >>> 16);
161
+ return { value: z, state };
162
+ }
@@ -0,0 +1,208 @@
1
+ import { describe, it, assert } from "../testing/harness.ts";
2
+ import {
3
+ query,
4
+ get,
5
+ has,
6
+ lt,
7
+ gt,
8
+ lte,
9
+ gte,
10
+ eq,
11
+ neq,
12
+ oneOf,
13
+ within,
14
+ allOf,
15
+ anyOf,
16
+ not,
17
+ } from "./query.ts";
18
+
19
+ const state = {
20
+ turn: 3,
21
+ party: [
22
+ { id: "alice", hp: 20, maxHp: 20, position: { x: 1, y: 2 }, role: "fighter" },
23
+ { id: "bob", hp: 8, maxHp: 15, position: { x: 3, y: 4 }, role: "mage" },
24
+ { id: "carol", hp: 0, maxHp: 12, position: { x: 5, y: 6 }, role: "rogue" },
25
+ ],
26
+ dungeon: {
27
+ level: 2,
28
+ rooms: [
29
+ { id: "r1", type: "corridor" },
30
+ { id: "r2", type: "treasure" },
31
+ { id: "r3", type: "corridor" },
32
+ ],
33
+ },
34
+ };
35
+
36
+ describe("get", () => {
37
+ it("gets a top-level value", () => {
38
+ assert.equal(get(state, "turn"), 3);
39
+ });
40
+
41
+ it("gets a nested value", () => {
42
+ assert.equal(get(state, "dungeon.level"), 2);
43
+ });
44
+
45
+ it("gets an array element by index", () => {
46
+ const alice = get<typeof state, typeof state.party[0]>(state, "party.0");
47
+ assert.equal(alice!.id, "alice");
48
+ });
49
+
50
+ it("gets a deeply nested value through array index", () => {
51
+ assert.equal(get(state, "party.1.hp"), 8);
52
+ });
53
+
54
+ it("returns undefined for missing paths", () => {
55
+ assert.equal(get(state, "nonexistent"), undefined);
56
+ assert.equal(get(state, "party.0.nonexistent"), undefined);
57
+ assert.equal(get(state, "a.b.c.d"), undefined);
58
+ });
59
+
60
+ it("supports wildcard paths to get all values", () => {
61
+ const hps = get(state, "party.*.hp");
62
+ assert.deepEqual(hps, [20, 8, 0]);
63
+ });
64
+ });
65
+
66
+ describe("has", () => {
67
+ it("returns true for existing paths", () => {
68
+ assert.equal(has(state, "turn"), true);
69
+ assert.equal(has(state, "party.0.hp"), true);
70
+ });
71
+
72
+ it("returns false for missing paths", () => {
73
+ assert.equal(has(state, "nonexistent"), false);
74
+ });
75
+
76
+ it("checks a predicate when provided", () => {
77
+ assert.equal(has(state, "turn", (v) => (v as number) > 2), true);
78
+ assert.equal(has(state, "turn", (v) => (v as number) > 5), false);
79
+ });
80
+ });
81
+
82
+ describe("query", () => {
83
+ it("returns an array at a path", () => {
84
+ const result = query(state, "party");
85
+ assert.equal(result.length, 3);
86
+ });
87
+
88
+ it("filters with a predicate function", () => {
89
+ const alive = query<typeof state, typeof state.party[0]>(
90
+ state,
91
+ "party",
92
+ (member) => member.hp > 0,
93
+ );
94
+ assert.equal(alive.length, 2);
95
+ assert.equal(alive[0].id, "alice");
96
+ assert.equal(alive[1].id, "bob");
97
+ });
98
+
99
+ it("filters with an object filter (property matching)", () => {
100
+ const fighters = query(state, "party", { role: "fighter" });
101
+ assert.equal(fighters.length, 1);
102
+ });
103
+
104
+ it("filters with predicate values in object filter", () => {
105
+ const wounded = query(state, "party", { hp: lt(15) });
106
+ assert.equal(wounded.length, 2); // bob (8) and carol (0)
107
+ });
108
+
109
+ it("wraps a non-array value as a single-element array", () => {
110
+ const result = query(state, "dungeon.level");
111
+ assert.deepEqual(result, [2]);
112
+ });
113
+
114
+ it("returns empty for missing paths", () => {
115
+ const result = query(state, "nonexistent");
116
+ assert.deepEqual(result, []);
117
+ });
118
+
119
+ it("queries nested arrays", () => {
120
+ const corridors = query(state, "dungeon.rooms", { type: "corridor" });
121
+ assert.equal(corridors.length, 2);
122
+ });
123
+ });
124
+
125
+ describe("filter combinators", () => {
126
+ it("lt", () => {
127
+ const f = lt(10);
128
+ assert.equal(f(5), true);
129
+ assert.equal(f(10), false);
130
+ assert.equal(f(15), false);
131
+ });
132
+
133
+ it("gt", () => {
134
+ const f = gt(10);
135
+ assert.equal(f(15), true);
136
+ assert.equal(f(10), false);
137
+ assert.equal(f(5), false);
138
+ });
139
+
140
+ it("lte", () => {
141
+ const f = lte(10);
142
+ assert.equal(f(10), true);
143
+ assert.equal(f(11), false);
144
+ });
145
+
146
+ it("gte", () => {
147
+ const f = gte(10);
148
+ assert.equal(f(10), true);
149
+ assert.equal(f(9), false);
150
+ });
151
+
152
+ it("eq", () => {
153
+ const f = eq("fighter");
154
+ assert.equal(f("fighter"), true);
155
+ assert.equal(f("mage"), false);
156
+ });
157
+
158
+ it("neq", () => {
159
+ const f = neq(0);
160
+ assert.equal(f(1), true);
161
+ assert.equal(f(0), false);
162
+ });
163
+
164
+ it("oneOf", () => {
165
+ const f = oneOf("fighter", "mage");
166
+ assert.equal(f("fighter"), true);
167
+ assert.equal(f("mage"), true);
168
+ assert.equal(f("rogue"), false);
169
+ });
170
+
171
+ it("within", () => {
172
+ const f = within({ x: 0, y: 0 }, 5);
173
+ assert.equal(f({ x: 3, y: 4 }), true); // distance = 5
174
+ assert.equal(f({ x: 4, y: 4 }), false); // distance > 5
175
+ assert.equal(f({ x: 0, y: 0 }), true); // distance = 0
176
+ });
177
+
178
+ it("allOf", () => {
179
+ const f = allOf(gt(0), lt(20));
180
+ assert.equal(f(10), true);
181
+ assert.equal(f(0), false);
182
+ assert.equal(f(20), false);
183
+ });
184
+
185
+ it("anyOf", () => {
186
+ const f = anyOf(eq(0), eq(20));
187
+ assert.equal(f(0), true);
188
+ assert.equal(f(20), true);
189
+ assert.equal(f(10), false);
190
+ });
191
+
192
+ it("not", () => {
193
+ const f = not(eq(0));
194
+ assert.equal(f(0), false);
195
+ assert.equal(f(1), true);
196
+ });
197
+
198
+ it("composing combinators", () => {
199
+ // Find party members who are alive and wounded (hp > 0 but hp < maxHp)
200
+ const aliveAndWounded = query<typeof state, typeof state.party[0]>(
201
+ state,
202
+ "party",
203
+ (member) => allOf(gt(0), lt(member.maxHp))(member.hp),
204
+ );
205
+ assert.equal(aliveAndWounded.length, 1);
206
+ assert.equal(aliveAndWounded[0].id, "bob");
207
+ });
208
+ });
@@ -0,0 +1,144 @@
1
+ import type { Vec2 } from "./types.ts";
2
+
3
+ /** A predicate function for filtering */
4
+ export type Predicate<T> = (item: T) => boolean;
5
+
6
+ /** Query an array at a path, with optional filtering */
7
+ export function query<S, R = unknown>(
8
+ state: S,
9
+ path: string,
10
+ filter?: Predicate<R> | Record<string, unknown>,
11
+ ): readonly R[] {
12
+ const value = getByPath(state, path);
13
+
14
+ if (!Array.isArray(value)) {
15
+ return value !== undefined ? ([value] as unknown as readonly R[]) : [];
16
+ }
17
+
18
+ if (!filter) return value as readonly R[];
19
+
20
+ if (typeof filter === "function") {
21
+ return value.filter(filter) as readonly R[];
22
+ }
23
+
24
+ // Object filter: match properties
25
+ return value.filter((item) => {
26
+ if (typeof item !== "object" || item === null) return false;
27
+ const record = item as Record<string, unknown>;
28
+ for (const [key, expected] of Object.entries(filter)) {
29
+ if (typeof expected === "function") {
30
+ if (!(expected as Predicate<unknown>)(record[key])) return false;
31
+ } else if (record[key] !== expected) {
32
+ return false;
33
+ }
34
+ }
35
+ return true;
36
+ }) as readonly R[];
37
+ }
38
+
39
+ /** Get a single value at a path */
40
+ export function get<S, R = unknown>(state: S, path: string): R | undefined {
41
+ return getByPath(state, path) as R | undefined;
42
+ }
43
+
44
+ /** Check existence at a path, optionally with a predicate */
45
+ export function has<S>(
46
+ state: S,
47
+ path: string,
48
+ predicate?: Predicate<unknown>,
49
+ ): boolean {
50
+ const value = getByPath(state, path);
51
+ if (value === undefined) return false;
52
+ if (predicate) return predicate(value);
53
+ return true;
54
+ }
55
+
56
+ // --- Filter combinators ---
57
+
58
+ /** Less than */
59
+ export function lt(value: number): Predicate<number> {
60
+ return (item: number) => item < value;
61
+ }
62
+
63
+ /** Greater than */
64
+ export function gt(value: number): Predicate<number> {
65
+ return (item: number) => item > value;
66
+ }
67
+
68
+ /** Less than or equal */
69
+ export function lte(value: number): Predicate<number> {
70
+ return (item: number) => item <= value;
71
+ }
72
+
73
+ /** Greater than or equal */
74
+ export function gte(value: number): Predicate<number> {
75
+ return (item: number) => item >= value;
76
+ }
77
+
78
+ /** Strict equality */
79
+ export function eq<T>(value: T): Predicate<T> {
80
+ return (item: T) => item === value;
81
+ }
82
+
83
+ /** Not equal */
84
+ export function neq<T>(value: T): Predicate<T> {
85
+ return (item: T) => item !== value;
86
+ }
87
+
88
+ /** Value is one of the given options */
89
+ export function oneOf<T>(...values: T[]): Predicate<T> {
90
+ return (item: T) => values.includes(item);
91
+ }
92
+
93
+ /** Position within radius of a center point */
94
+ export function within(center: Vec2, radius: number): Predicate<Vec2> {
95
+ return (pos: Vec2) => {
96
+ const dx = pos.x - center.x;
97
+ const dy = pos.y - center.y;
98
+ return dx * dx + dy * dy <= radius * radius;
99
+ };
100
+ }
101
+
102
+ /** Combine predicates: all must pass */
103
+ export function allOf<T>(...predicates: Predicate<T>[]): Predicate<T> {
104
+ return (item: T) => predicates.every((p) => p(item));
105
+ }
106
+
107
+ /** Combine predicates: any must pass */
108
+ export function anyOf<T>(...predicates: Predicate<T>[]): Predicate<T> {
109
+ return (item: T) => predicates.some((p) => p(item));
110
+ }
111
+
112
+ /** Negate a predicate */
113
+ export function not<T>(predicate: Predicate<T>): Predicate<T> {
114
+ return (item: T) => !predicate(item);
115
+ }
116
+
117
+ // --- Internal: path traversal with wildcard support ---
118
+
119
+ function getByPath(obj: unknown, path: string): unknown {
120
+ const segments = path.split(".");
121
+ return resolveSegments(obj, segments);
122
+ }
123
+
124
+ function resolveSegments(obj: unknown, segments: string[]): unknown {
125
+ if (segments.length === 0) return obj;
126
+
127
+ const [head, ...rest] = segments;
128
+
129
+ if (head === "*") {
130
+ // Wildcard: expand across array elements
131
+ if (!Array.isArray(obj)) return undefined;
132
+ if (rest.length === 0) return obj;
133
+ return obj.flatMap((item) => {
134
+ const result = resolveSegments(item, rest);
135
+ return Array.isArray(result) ? result : [result];
136
+ });
137
+ }
138
+
139
+ if (obj === null || obj === undefined) return undefined;
140
+ if (typeof obj !== "object") return undefined;
141
+
142
+ const next = (obj as Record<string, unknown>)[head];
143
+ return resolveSegments(next, rest);
144
+ }