@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,60 @@
1
+ import type { SpriteOptions } from "./types.ts";
2
+
3
+ // Detect if we're running inside the Arcane renderer (V8 with render ops).
4
+ const hasRenderOps =
5
+ typeof (globalThis as any).Deno !== "undefined" &&
6
+ typeof (globalThis as any).Deno?.core?.ops?.op_draw_sprite === "function";
7
+
8
+ /**
9
+ * Queue a sprite to be drawn this frame.
10
+ * No-op in headless mode (Node or V8 test runner).
11
+ */
12
+ export function drawSprite(opts: SpriteOptions): void {
13
+ if (!hasRenderOps) return;
14
+
15
+ const uv = opts.uv ?? { x: 0, y: 0, w: 1, h: 1 };
16
+ const tint = opts.tint ?? { r: 1, g: 1, b: 1, a: 1 };
17
+
18
+ // Extract ALL properties to temp variables to work around V8 object literal property access bug
19
+ // Bug: accessing multiple properties from same object in function args causes all to evaluate to parent
20
+ const texId = opts.textureId;
21
+ const x = opts.x;
22
+ const y = opts.y;
23
+ const w = opts.w;
24
+ const h = opts.h;
25
+ const layer = opts.layer ?? 0;
26
+ const uvX = uv.x;
27
+ const uvY = uv.y;
28
+ const uvW = uv.w;
29
+ const uvH = uv.h;
30
+ const tintR = tint.r;
31
+ const tintG = tint.g;
32
+ const tintB = tint.b;
33
+ const tintA = tint.a;
34
+
35
+ (globalThis as any).Deno.core.ops.op_draw_sprite(
36
+ texId,
37
+ x,
38
+ y,
39
+ w,
40
+ h,
41
+ layer,
42
+ uvX,
43
+ uvY,
44
+ uvW,
45
+ uvH,
46
+ tintR,
47
+ tintG,
48
+ tintB,
49
+ tintA,
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Clear all queued sprites for this frame.
55
+ * No-op in headless mode.
56
+ */
57
+ export function clearSprites(): void {
58
+ if (!hasRenderOps) return;
59
+ (globalThis as any).Deno.core.ops.op_clear_sprites();
60
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, it, assert } from "../../runtime/testing/harness.ts";
2
+ import {
3
+ loadFont,
4
+ getDefaultFont,
5
+ measureText,
6
+ drawText,
7
+ } from "./text.ts";
8
+ import type { BitmapFont } from "./text.ts";
9
+
10
+ describe("text", () => {
11
+ it("loadFont creates BitmapFont with correct fields", () => {
12
+ const font = loadFont(42, 16, 16, 8, 12, 0);
13
+ assert.equal(font.textureId, 42);
14
+ assert.equal(font.glyphW, 16);
15
+ assert.equal(font.glyphH, 16);
16
+ assert.equal(font.columns, 8);
17
+ assert.equal(font.rows, 12);
18
+ assert.equal(font.firstChar, 0);
19
+ });
20
+
21
+ it("loadFont uses default firstChar=32", () => {
22
+ const font = loadFont(1, 8, 8, 16, 6);
23
+ assert.equal(font.firstChar, 32);
24
+ });
25
+
26
+ it("getDefaultFont returns a font with expected dimensions", () => {
27
+ const font = getDefaultFont();
28
+ assert.equal(font.glyphW, 8);
29
+ assert.equal(font.glyphH, 8);
30
+ assert.equal(font.columns, 16);
31
+ assert.equal(font.rows, 6);
32
+ assert.equal(font.firstChar, 32);
33
+ });
34
+
35
+ it("getDefaultFont returns same instance on second call", () => {
36
+ const a = getDefaultFont();
37
+ const b = getDefaultFont();
38
+ assert.ok(a === b, "expected same object reference");
39
+ });
40
+
41
+ it("measureText returns correct width for known string", () => {
42
+ const m = measureText("Hello");
43
+ // Default font: 8px wide glyphs, scale 1 → 5 * 8 = 40
44
+ assert.equal(m.width, 40);
45
+ assert.equal(m.height, 8);
46
+ });
47
+
48
+ it("measureText with scale multiplier", () => {
49
+ const m = measureText("AB", { scale: 3 });
50
+ // 2 chars * 8 * 3 = 48 wide, 8 * 3 = 24 high
51
+ assert.equal(m.width, 48);
52
+ assert.equal(m.height, 24);
53
+ });
54
+
55
+ it("measureText with custom font dimensions", () => {
56
+ const font = loadFont(0, 12, 16, 16, 6);
57
+ const m = measureText("Hi!", { font });
58
+ // 3 chars * 12 * 1 = 36 wide, 16 high
59
+ assert.equal(m.width, 36);
60
+ assert.equal(m.height, 16);
61
+ });
62
+
63
+ it("drawText doesn't throw in headless mode", () => {
64
+ // In headless (Node/V8 test runner), drawText is a no-op
65
+ drawText("Hello, world!", 10, 20);
66
+ drawText("Test", 0, 0, { scale: 2, layer: 50 });
67
+ assert.ok(true, "drawText completed without error");
68
+ });
69
+
70
+ it("UV calculation: char A (code 65) gives correct col and row", () => {
71
+ // 'A' = charCode 65, minus firstChar 32 = 33
72
+ // col = 33 % 16 = 1, row = floor(33 / 16) = 2
73
+ const font = getDefaultFont();
74
+ const charCode = "A".charCodeAt(0) - font.firstChar; // 33
75
+ const col = charCode % font.columns;
76
+ const row = Math.floor(charCode / font.columns);
77
+ assert.equal(charCode, 33);
78
+ assert.equal(col, 1);
79
+ assert.equal(row, 2);
80
+ });
81
+
82
+ it("UV calculation: space (code 32) gives col=0 row=0", () => {
83
+ const font = getDefaultFont();
84
+ const charCode = " ".charCodeAt(0) - font.firstChar; // 0
85
+ const col = charCode % font.columns;
86
+ const row = Math.floor(charCode / font.columns);
87
+ assert.equal(charCode, 0);
88
+ assert.equal(col, 0);
89
+ assert.equal(row, 0);
90
+ });
91
+ });
@@ -0,0 +1,184 @@
1
+ import type { TextureId } from "./types.ts";
2
+ import { drawSprite } from "./sprites.ts";
3
+ import { getCamera } from "./camera.ts";
4
+
5
+ // --- Types ---
6
+
7
+ export type BitmapFont = {
8
+ textureId: TextureId;
9
+ glyphW: number;
10
+ glyphH: number;
11
+ columns: number;
12
+ rows: number;
13
+ firstChar: number;
14
+ };
15
+
16
+ export type TextOptions = {
17
+ font?: BitmapFont;
18
+ scale?: number;
19
+ tint?: { r: number; g: number; b: number; a: number };
20
+ layer?: number;
21
+ screenSpace?: boolean;
22
+ };
23
+
24
+ export type TextMeasurement = {
25
+ width: number;
26
+ height: number;
27
+ };
28
+
29
+ // --- Render ops detection ---
30
+
31
+ const hasRenderOps =
32
+ typeof (globalThis as any).Deno !== "undefined" &&
33
+ typeof (globalThis as any).Deno?.core?.ops?.op_draw_sprite === "function";
34
+
35
+ const hasFontOp =
36
+ typeof (globalThis as any).Deno?.core?.ops?.op_create_font_texture ===
37
+ "function";
38
+
39
+ const hasViewportOp =
40
+ typeof (globalThis as any).Deno?.core?.ops?.op_get_viewport_size ===
41
+ "function";
42
+
43
+ function getViewportSize(): [number, number] {
44
+ if (!hasViewportOp) return [800, 600];
45
+ const [w, h] = (globalThis as any).Deno.core.ops.op_get_viewport_size();
46
+ return [w, h];
47
+ }
48
+
49
+ // --- Module state ---
50
+
51
+ let defaultFont: BitmapFont | null = null;
52
+
53
+ // --- Functions ---
54
+
55
+ /**
56
+ * Create a bitmap font descriptor from a texture atlas.
57
+ */
58
+ export function loadFont(
59
+ textureId: TextureId,
60
+ glyphW: number,
61
+ glyphH: number,
62
+ columns: number,
63
+ rows: number,
64
+ firstChar: number = 32,
65
+ ): BitmapFont {
66
+ return { textureId, glyphW, glyphH, columns, rows, firstChar };
67
+ }
68
+
69
+ /**
70
+ * Get the default 8x8 bitmap font, lazily initialized.
71
+ * In headless mode returns a dummy font (textureId 0).
72
+ */
73
+ export function getDefaultFont(): BitmapFont {
74
+ if (defaultFont !== null) return defaultFont;
75
+
76
+ if (hasFontOp) {
77
+ const textureId: TextureId = (
78
+ globalThis as any
79
+ ).Deno.core.ops.op_create_font_texture();
80
+ defaultFont = {
81
+ textureId,
82
+ glyphW: 8,
83
+ glyphH: 8,
84
+ columns: 16,
85
+ rows: 6,
86
+ firstChar: 32,
87
+ };
88
+ } else {
89
+ defaultFont = {
90
+ textureId: 0,
91
+ glyphW: 8,
92
+ glyphH: 8,
93
+ columns: 16,
94
+ rows: 6,
95
+ firstChar: 32,
96
+ };
97
+ }
98
+
99
+ return defaultFont;
100
+ }
101
+
102
+ /**
103
+ * Measure the pixel dimensions of a text string.
104
+ * Pure math — works in headless mode.
105
+ */
106
+ export function measureText(
107
+ text: string,
108
+ options?: TextOptions,
109
+ ): TextMeasurement {
110
+ const font = options?.font ?? getDefaultFont();
111
+ const scale = options?.scale ?? 1;
112
+ return {
113
+ width: text.length * font.glyphW * scale,
114
+ height: font.glyphH * scale,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Draw a text string using the sprite pipeline.
120
+ * Each character becomes one drawSprite() call.
121
+ * No-op in headless mode.
122
+ */
123
+ export function drawText(
124
+ text: string,
125
+ x: number,
126
+ y: number,
127
+ options?: TextOptions,
128
+ ): void {
129
+ if (!hasRenderOps) return;
130
+
131
+ const font = options?.font ?? getDefaultFont();
132
+ const scale = options?.scale ?? 1;
133
+ const layer = options?.layer ?? 100;
134
+ const tint = options?.tint;
135
+ const screenSpace = options?.screenSpace ?? false;
136
+
137
+ const maxChar = font.columns * font.rows;
138
+
139
+ for (let i = 0; i < text.length; i++) {
140
+ const charCode = text.charCodeAt(i) - font.firstChar;
141
+ if (charCode < 0 || charCode >= maxChar) continue;
142
+
143
+ const col = charCode % font.columns;
144
+ const row = Math.floor(charCode / font.columns);
145
+ const uv = {
146
+ x: col / font.columns,
147
+ y: row / font.rows,
148
+ w: 1 / font.columns,
149
+ h: 1 / font.rows,
150
+ };
151
+
152
+ const drawX = x + i * font.glyphW * scale;
153
+
154
+ let worldX: number;
155
+ let worldY: number;
156
+ let spriteW: number;
157
+ let spriteH: number;
158
+
159
+ if (screenSpace) {
160
+ const cam = getCamera();
161
+ const [viewportW, viewportH] = getViewportSize();
162
+ worldX = drawX / cam.zoom + cam.x - viewportW / (2 * cam.zoom);
163
+ worldY = y / cam.zoom + cam.y - viewportH / (2 * cam.zoom);
164
+ spriteW = (font.glyphW * scale) / cam.zoom;
165
+ spriteH = (font.glyphH * scale) / cam.zoom;
166
+ } else {
167
+ worldX = drawX;
168
+ worldY = y;
169
+ spriteW = font.glyphW * scale;
170
+ spriteH = font.glyphH * scale;
171
+ }
172
+
173
+ drawSprite({
174
+ textureId: font.textureId,
175
+ x: worldX,
176
+ y: worldY,
177
+ w: spriteW,
178
+ h: spriteH,
179
+ layer,
180
+ uv,
181
+ tint,
182
+ });
183
+ }
184
+ }
@@ -0,0 +1,31 @@
1
+ import type { TextureId } from "./types.ts";
2
+
3
+ const hasRenderOps =
4
+ typeof (globalThis as any).Deno !== "undefined" &&
5
+ typeof (globalThis as any).Deno?.core?.ops?.op_load_texture === "function";
6
+
7
+ /**
8
+ * Load a texture from a PNG file path. Returns a texture handle.
9
+ * Caches by path — loading the same path twice returns the same handle.
10
+ * Returns 0 (no texture) in headless mode.
11
+ */
12
+ export function loadTexture(path: string): TextureId {
13
+ if (!hasRenderOps) return 0;
14
+ return (globalThis as any).Deno.core.ops.op_load_texture(path);
15
+ }
16
+
17
+ /**
18
+ * Create a solid-color 1x1 texture. Useful for placeholder sprites.
19
+ * Colors are 0-255 RGBA.
20
+ * Returns 0 (no texture) in headless mode.
21
+ */
22
+ export function createSolidTexture(
23
+ name: string,
24
+ r: number,
25
+ g: number,
26
+ b: number,
27
+ a: number = 255,
28
+ ): TextureId {
29
+ if (!hasRenderOps) return 0;
30
+ return (globalThis as any).Deno.core.ops.op_create_solid_texture(name, r, g, b, a);
31
+ }
@@ -0,0 +1,46 @@
1
+ import type { TilemapId, TilemapOptions } from "./types.ts";
2
+
3
+ const hasRenderOps =
4
+ typeof (globalThis as any).Deno !== "undefined" &&
5
+ typeof (globalThis as any).Deno?.core?.ops?.op_create_tilemap === "function";
6
+
7
+ /** Create a tilemap backed by a texture atlas. Returns a TilemapId handle. */
8
+ export function createTilemap(opts: TilemapOptions): TilemapId {
9
+ if (!hasRenderOps) return 0;
10
+ return (globalThis as any).Deno.core.ops.op_create_tilemap(
11
+ opts.textureId,
12
+ opts.width,
13
+ opts.height,
14
+ opts.tileSize,
15
+ opts.atlasColumns,
16
+ opts.atlasRows,
17
+ );
18
+ }
19
+
20
+ /** Set a tile at grid position (gx, gy). Tile ID 0 = empty. */
21
+ export function setTile(
22
+ id: TilemapId,
23
+ gx: number,
24
+ gy: number,
25
+ tileId: number,
26
+ ): void {
27
+ if (!hasRenderOps) return;
28
+ (globalThis as any).Deno.core.ops.op_set_tile(id, gx, gy, tileId);
29
+ }
30
+
31
+ /** Get the tile ID at grid position (gx, gy). Returns 0 if out of bounds. */
32
+ export function getTile(id: TilemapId, gx: number, gy: number): number {
33
+ if (!hasRenderOps) return 0;
34
+ return (globalThis as any).Deno.core.ops.op_get_tile(id, gx, gy);
35
+ }
36
+
37
+ /** Draw all visible tiles as sprites (camera-culled). */
38
+ export function drawTilemap(
39
+ id: TilemapId,
40
+ x: number = 0,
41
+ y: number = 0,
42
+ layer: number = 0,
43
+ ): void {
44
+ if (!hasRenderOps) return;
45
+ (globalThis as any).Deno.core.ops.op_draw_tilemap(id, x, y, layer);
46
+ }
@@ -0,0 +1,54 @@
1
+ /** Opaque handle to a loaded texture. */
2
+ export type TextureId = number;
3
+
4
+ /** Options for drawing a sprite. */
5
+ export type SpriteOptions = {
6
+ /** Texture handle from loadTexture() or createSolidTexture(). */
7
+ textureId: TextureId;
8
+ /** World X position (top-left corner). */
9
+ x: number;
10
+ /** World Y position (top-left corner). */
11
+ y: number;
12
+ /** Width in world units. */
13
+ w: number;
14
+ /** Height in world units. */
15
+ h: number;
16
+ /** Draw order layer (lower = drawn first / behind). Default: 0. */
17
+ layer?: number;
18
+ /** UV sub-rect for atlas sprites. Default: full texture. */
19
+ uv?: { x: number; y: number; w: number; h: number };
20
+ /** RGBA tint color (0-1 range). Default: white (1,1,1,1). */
21
+ tint?: { r: number; g: number; b: number; a: number };
22
+ };
23
+
24
+ /** Camera state. */
25
+ export type CameraState = {
26
+ x: number;
27
+ y: number;
28
+ zoom: number;
29
+ };
30
+
31
+ /** Mouse position. */
32
+ export type MousePosition = {
33
+ x: number;
34
+ y: number;
35
+ };
36
+
37
+ /** Opaque handle to a tilemap. */
38
+ export type TilemapId = number;
39
+
40
+ /** Options for creating a tilemap. */
41
+ export type TilemapOptions = {
42
+ /** Texture atlas handle from loadTexture(). */
43
+ textureId: number;
44
+ /** Grid width in tiles. */
45
+ width: number;
46
+ /** Grid height in tiles. */
47
+ height: number;
48
+ /** Size of each tile in world units. */
49
+ tileSize: number;
50
+ /** Number of columns in the texture atlas. */
51
+ atlasColumns: number;
52
+ /** Number of rows in the texture atlas. */
53
+ atlasRows: number;
54
+ };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Runtime validation for rendering API calls
3
+ * Add this to production code to catch errors early
4
+ */
5
+
6
+ export interface ValidationOptions {
7
+ throwOnError?: boolean;
8
+ logErrors?: boolean;
9
+ }
10
+
11
+ const defaultOptions: ValidationOptions = {
12
+ throwOnError: true,
13
+ logErrors: false,
14
+ };
15
+
16
+ export function validateNumber(
17
+ api: string,
18
+ param: string,
19
+ value: any,
20
+ options: ValidationOptions = defaultOptions
21
+ ): boolean {
22
+ if (typeof value !== "number") {
23
+ const error = `${api}: ${param} must be number, got ${typeof value}`;
24
+ return handleError(error, options);
25
+ }
26
+
27
+ if (isNaN(value)) {
28
+ const error = `${api}: ${param} is NaN`;
29
+ return handleError(error, options);
30
+ }
31
+
32
+ if (!isFinite(value)) {
33
+ const error = `${api}: ${param} is not finite`;
34
+ return handleError(error, options);
35
+ }
36
+
37
+ return true;
38
+ }
39
+
40
+ export function validateColor(
41
+ api: string,
42
+ color: any,
43
+ options: ValidationOptions = defaultOptions
44
+ ): boolean {
45
+ if (typeof color !== "object" || color === null) {
46
+ const error = `${api}: color must be object`;
47
+ return handleError(error, options);
48
+ }
49
+
50
+ const { r, g, b } = color;
51
+
52
+ if (typeof r !== "number" || r < 0 || r > 1) {
53
+ const error = `${api}: color.r must be 0.0-1.0, got ${r}`;
54
+ return handleError(error, options);
55
+ }
56
+
57
+ if (typeof g !== "number" || g < 0 || g > 1) {
58
+ const error = `${api}: color.g must be 0.0-1.0, got ${g}`;
59
+ return handleError(error, options);
60
+ }
61
+
62
+ if (typeof b !== "number" || b < 0 || b > 1) {
63
+ const error = `${api}: color.b must be 0.0-1.0, got ${b}`;
64
+ return handleError(error, options);
65
+ }
66
+
67
+ return true;
68
+ }
69
+
70
+ export function validateTextOptions(
71
+ opts: any,
72
+ options: ValidationOptions = defaultOptions
73
+ ): boolean {
74
+ if (typeof opts !== "object" || opts === null) {
75
+ const error = "drawText: options must be object";
76
+ return handleError(error, options);
77
+ }
78
+
79
+ if (!validateNumber("drawText", "x", opts.x, options)) return false;
80
+ if (!validateNumber("drawText", "y", opts.y, options)) return false;
81
+ if (!validateNumber("drawText", "size", opts.size, options)) return false;
82
+
83
+ if (opts.color) {
84
+ if (!validateColor("drawText", opts.color, options)) return false;
85
+ }
86
+
87
+ return true;
88
+ }
89
+
90
+ export function validateRectParams(
91
+ x: any,
92
+ y: any,
93
+ w: any,
94
+ h: any,
95
+ opts?: any,
96
+ options: ValidationOptions = defaultOptions
97
+ ): boolean {
98
+ if (!validateNumber("drawRect", "x", x, options)) return false;
99
+ if (!validateNumber("drawRect", "y", y, options)) return false;
100
+ if (!validateNumber("drawRect", "w", w, options)) return false;
101
+ if (!validateNumber("drawRect", "h", h, options)) return false;
102
+
103
+ if (opts?.color) {
104
+ if (!validateColor("drawRect", opts.color, options)) return false;
105
+ }
106
+
107
+ return true;
108
+ }
109
+
110
+ function handleError(message: string, options: ValidationOptions): boolean {
111
+ if (options.logErrors) {
112
+ console.error(`[Render Validation] ${message}`);
113
+ }
114
+
115
+ if (options.throwOnError) {
116
+ throw new Error(message);
117
+ }
118
+
119
+ return false;
120
+ }
121
+
122
+ // Helper: wrap a rendering function with validation
123
+ export function withValidation<T extends (...args: any[]) => any>(
124
+ fn: T,
125
+ validator: (...args: Parameters<T>) => boolean
126
+ ): T {
127
+ return ((...args: Parameters<T>) => {
128
+ if (validator(...args)) {
129
+ return fn(...args);
130
+ }
131
+ }) as T;
132
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, assert } from "../testing/harness.ts";
2
+ import { createError } from "./error.ts";
3
+
4
+ describe("createError", () => {
5
+ it("creates an error with code, message, and context", () => {
6
+ const err = createError(
7
+ "COMBAT_TARGET_OUT_OF_RANGE",
8
+ "Cannot attack goblin_3: distance 7 exceeds weapon range 5",
9
+ {
10
+ action: "attack",
11
+ reason: "target out of range",
12
+ state: { distance: 7, range: 5 },
13
+ suggestion: "Move closer or use a ranged weapon",
14
+ },
15
+ );
16
+
17
+ assert.equal(err.code, "COMBAT_TARGET_OUT_OF_RANGE");
18
+ assert.equal(err.message, "Cannot attack goblin_3: distance 7 exceeds weapon range 5");
19
+ assert.equal(err.context.action, "attack");
20
+ assert.equal(err.context.reason, "target out of range");
21
+ assert.deepEqual(err.context.state, { distance: 7, range: 5 });
22
+ assert.equal(err.context.suggestion, "Move closer or use a ranged weapon");
23
+ });
24
+
25
+ it("works without optional fields", () => {
26
+ const err = createError("INVALID_PATH", "Path not found", {
27
+ action: "query",
28
+ reason: "path does not exist",
29
+ });
30
+
31
+ assert.equal(err.context.state, undefined);
32
+ assert.equal(err.context.suggestion, undefined);
33
+ });
34
+
35
+ it("is JSON-serializable", () => {
36
+ const err = createError("TEST", "test error", {
37
+ action: "test",
38
+ reason: "testing",
39
+ state: { hp: 0 },
40
+ });
41
+
42
+ const roundTripped = JSON.parse(JSON.stringify(err));
43
+ assert.deepEqual(roundTripped, err);
44
+ });
45
+ });
@@ -0,0 +1,20 @@
1
+ /** Structured error type — per docs/api-design.md */
2
+ export type ArcaneError = Readonly<{
3
+ code: string;
4
+ message: string;
5
+ context: Readonly<{
6
+ action: string;
7
+ reason: string;
8
+ state?: Readonly<Record<string, unknown>>;
9
+ suggestion?: string;
10
+ }>;
11
+ }>;
12
+
13
+ /** Create an ArcaneError */
14
+ export function createError(
15
+ code: string,
16
+ message: string,
17
+ context: ArcaneError["context"],
18
+ ): ArcaneError {
19
+ return { code, message, context };
20
+ }