@forgeax/engine-project 0.1.2

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 (48) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +82 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/ac14-migration.test.d.ts +2 -0
  5. package/dist/__tests__/ac14-migration.test.d.ts.map +1 -0
  6. package/dist/__tests__/errors.test-d.d.ts +2 -0
  7. package/dist/__tests__/errors.test-d.d.ts.map +1 -0
  8. package/dist/__tests__/errors.test.d.ts +2 -0
  9. package/dist/__tests__/errors.test.d.ts.map +1 -0
  10. package/dist/__tests__/loader.test.d.ts +2 -0
  11. package/dist/__tests__/loader.test.d.ts.map +1 -0
  12. package/dist/__tests__/resolve.test.d.ts +2 -0
  13. package/dist/__tests__/resolve.test.d.ts.map +1 -0
  14. package/dist/__tests__/schema.test.d.ts +2 -0
  15. package/dist/__tests__/schema.test.d.ts.map +1 -0
  16. package/dist/__tests__/structural.test.d.ts +2 -0
  17. package/dist/__tests__/structural.test.d.ts.map +1 -0
  18. package/dist/errors.d.ts +84 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.mjs +291 -0
  23. package/dist/index.mjs.map +1 -0
  24. package/dist/loader.d.ts +99 -0
  25. package/dist/loader.d.ts.map +1 -0
  26. package/dist/paths.d.ts +3 -0
  27. package/dist/paths.d.ts.map +1 -0
  28. package/dist/schema.d.ts +216 -0
  29. package/dist/schema.d.ts.map +1 -0
  30. package/package.json +65 -0
  31. package/src/__tests__/ac14-migration.test.ts +124 -0
  32. package/src/__tests__/errors.test-d.ts +77 -0
  33. package/src/__tests__/errors.test.ts +254 -0
  34. package/src/__tests__/fixtures/games/cow-level/forge.json +7 -0
  35. package/src/__tests__/fixtures/games/cow-survivor/forge.json +8 -0
  36. package/src/__tests__/fixtures/games/fps/forge.json +7 -0
  37. package/src/__tests__/fixtures/games/hellforge/forge.json +24 -0
  38. package/src/__tests__/fixtures/games/shoot-opt/forge.json +6 -0
  39. package/src/__tests__/fixtures/games/spin-cube/forge.json +6 -0
  40. package/src/__tests__/loader.test.ts +364 -0
  41. package/src/__tests__/resolve.test.ts +160 -0
  42. package/src/__tests__/schema.test.ts +317 -0
  43. package/src/__tests__/structural.test.ts +175 -0
  44. package/src/errors.ts +129 -0
  45. package/src/index.ts +37 -0
  46. package/src/loader.ts +344 -0
  47. package/src/paths.ts +7 -0
  48. package/src/schema.ts +149 -0
@@ -0,0 +1,254 @@
1
+ // errors.test.ts — w3: GameProjectError code union exhaustive tests
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { GameProjectErrorCode, GameProjectErrorDetail } from '../errors.js';
4
+ import { GameProjectError } from '../errors.js';
5
+
6
+ // ── helper ───────────────────────────────────────────────────────────────────
7
+ function makeErr(): GameProjectError {
8
+ return new GameProjectError({
9
+ code: 'forge-missing',
10
+ expected: 'test expected',
11
+ hint: 'test hint',
12
+ detail: { path: '/fake/forge.json' },
13
+ });
14
+ }
15
+
16
+ const errors: GameProjectError[] = [
17
+ new GameProjectError({
18
+ code: 'forge-missing',
19
+ expected: 'test expected',
20
+ hint: 'test hint',
21
+ detail: { path: '/fake/forge.json' },
22
+ }),
23
+ new GameProjectError({
24
+ code: 'forge-read-failed',
25
+ expected: 'forge.json to be readable',
26
+ hint: 'retry the project reader',
27
+ detail: { path: '/fake/forge.json', cause: 'transient' },
28
+ }),
29
+ new GameProjectError({
30
+ code: 'forge-parse-failed',
31
+ expected: 'test expected',
32
+ hint: 'test hint',
33
+ detail: { path: '/fake/forge.json', rawMessage: 'invalid JSON' },
34
+ }),
35
+ new GameProjectError({
36
+ code: 'forge-schema-invalid',
37
+ expected: 'test expected',
38
+ hint: 'test hint',
39
+ detail: { path: '/fake/forge.json', zodErrors: [] },
40
+ }),
41
+ new GameProjectError({
42
+ code: 'forge-unknown-field',
43
+ expected: 'test expected',
44
+ hint: 'test hint',
45
+ detail: { path: '/fake/forge.json', fieldNames: ['scenes'] },
46
+ }),
47
+ new GameProjectError({
48
+ code: 'forge-guid-malformed',
49
+ expected: 'test expected',
50
+ hint: 'test hint',
51
+ detail: { field: 'defaultScene', rawInput: 'bad-guid', cause: new Error('bad GUID') },
52
+ }),
53
+ new GameProjectError({
54
+ code: 'forge-scene-unresolved',
55
+ expected: 'test expected',
56
+ hint: 'test hint',
57
+ detail: { guid: '15acc839-d847-527c-8284-bfb36d7c50de' },
58
+ }),
59
+ ];
60
+
61
+ // ── four-property surface ───────────────────────────────────────────────────
62
+ describe('GameProjectError — structural surface', () => {
63
+ it('has .code, .expected, .hint, .detail', () => {
64
+ const err = makeErr();
65
+ expect(err).toHaveProperty('code');
66
+ expect(err).toHaveProperty('expected');
67
+ expect(err).toHaveProperty('hint');
68
+ expect(err).toHaveProperty('detail');
69
+ });
70
+
71
+ it('.code is readonly (TS compile-time, not runtime)', () => {
72
+ const err = makeErr();
73
+ // TS `readonly` is compile-time only; at runtime the property exists and
74
+ // holds its value. AI users cannot assign via the type (charter P2).
75
+ expect(err.code).toBe('forge-missing');
76
+ expect(typeof err.code).toBe('string');
77
+ });
78
+
79
+ it('.expected is readonly (TS compile-time)', () => {
80
+ const err = makeErr();
81
+ expect(err.expected).toBe('test expected');
82
+ expect(typeof err.expected).toBe('string');
83
+ });
84
+
85
+ it('.hint is readonly (TS compile-time)', () => {
86
+ const err = makeErr();
87
+ expect(err.hint).toBe('test hint');
88
+ expect(typeof err.hint).toBe('string');
89
+ });
90
+
91
+ it('.detail is readonly (TS compile-time)', () => {
92
+ const err = makeErr();
93
+ expect(err.detail).toBeDefined();
94
+ expect(typeof err.detail).toBe('object');
95
+ });
96
+ });
97
+
98
+ // ── code union membership ───────────────────────────────────────────────────
99
+ describe('GameProjectError — code union', () => {
100
+ const codes: GameProjectErrorCode[] = [
101
+ 'forge-missing',
102
+ 'forge-read-failed',
103
+ 'forge-parse-failed',
104
+ 'forge-schema-invalid',
105
+ 'forge-unknown-field',
106
+ 'forge-guid-malformed',
107
+ 'forge-scene-unresolved',
108
+ ];
109
+
110
+ it('code union has exactly 7 members', () => {
111
+ expect(codes).toHaveLength(7);
112
+ });
113
+
114
+ it('each code is constructable', () => {
115
+ expect(errors.map((error) => error.code)).toEqual(codes);
116
+ for (const err of errors) {
117
+ expect(err).toBeInstanceOf(Error);
118
+ expect(err).toBeInstanceOf(GameProjectError);
119
+ }
120
+ });
121
+
122
+ it('compiles exhaustive switch without default', () => {
123
+ function switchOnCode(code: GameProjectErrorCode): string {
124
+ switch (code) {
125
+ case 'forge-missing':
126
+ return 'missing';
127
+ case 'forge-read-failed':
128
+ return 'read-failed';
129
+ case 'forge-parse-failed':
130
+ return 'parse-failed';
131
+ case 'forge-schema-invalid':
132
+ return 'schema-invalid';
133
+ case 'forge-unknown-field':
134
+ return 'unknown-field';
135
+ case 'forge-guid-malformed':
136
+ return 'guid-malformed';
137
+ case 'forge-scene-unresolved':
138
+ return 'scene-unresolved';
139
+ }
140
+ }
141
+ expect(switchOnCode('forge-missing')).toBe('missing');
142
+ expect(switchOnCode('forge-read-failed')).toBe('read-failed');
143
+ expect(switchOnCode('forge-scene-unresolved')).toBe('scene-unresolved');
144
+ });
145
+ });
146
+
147
+ function describeError(error: GameProjectError): string {
148
+ switch (error.code) {
149
+ case 'forge-missing':
150
+ return `missing:${error.detail.path}`;
151
+ case 'forge-read-failed':
152
+ return `read-failed:${error.detail.path}:${error.detail.cause}`;
153
+ case 'forge-parse-failed':
154
+ return `parse:${error.detail.path}:${error.detail.rawMessage}`;
155
+ case 'forge-schema-invalid':
156
+ return `schema:${error.detail.path}:${error.detail.zodErrors.length}`;
157
+ case 'forge-unknown-field':
158
+ return `unknown:${error.detail.path}:${error.detail.fieldNames.join(',')}`;
159
+ case 'forge-guid-malformed':
160
+ return `guid:${error.detail.field}:${error.detail.rawInput}`;
161
+ case 'forge-scene-unresolved':
162
+ return `scene:${error.detail.guid}`;
163
+ }
164
+ }
165
+
166
+ describe('GameProjectError — code-correlated error narrowing', () => {
167
+ it('narrows representative detail payloads from error.code', () => {
168
+ expect(errors.map(describeError)).toEqual([
169
+ 'missing:/fake/forge.json',
170
+ 'read-failed:/fake/forge.json:transient',
171
+ 'parse:/fake/forge.json:invalid JSON',
172
+ 'schema:/fake/forge.json:0',
173
+ 'unknown:/fake/forge.json:scenes',
174
+ 'guid:defaultScene:bad-guid',
175
+ 'scene:15acc839-d847-527c-8284-bfb36d7c50de',
176
+ ]);
177
+ });
178
+ });
179
+
180
+ // ── per-code detail narrowing ───────────────────────────────────────────────
181
+ describe('GameProjectError — detail narrowing per code', () => {
182
+ it('forge-missing detail contains path:string', () => {
183
+ const _detail: Extract<GameProjectErrorDetail, { path: string }> = {
184
+ path: '/some/game/forge.json',
185
+ };
186
+ expect(_detail.path).toBe('/some/game/forge.json');
187
+ });
188
+
189
+ it('forge-read-failed detail contains path + bounded cause', () => {
190
+ const _detail: Extract<GameProjectErrorDetail, { path: string; cause: string }> = {
191
+ path: '/some/game/forge.json',
192
+ cause: 'permission-denied',
193
+ };
194
+ expect(_detail.cause).toBe('permission-denied');
195
+ });
196
+
197
+ it('forge-parse-failed detail contains path + rawMessage', () => {
198
+ const _detail: Extract<GameProjectErrorDetail, { path: string; rawMessage: string }> = {
199
+ path: '/some/forge.json',
200
+ rawMessage: 'Unexpected token',
201
+ };
202
+ expect(_detail.rawMessage).toBe('Unexpected token');
203
+ });
204
+
205
+ it('forge-schema-invalid detail contains path + zodErrors', () => {
206
+ const _detail: Extract<GameProjectErrorDetail, { path: string; zodErrors: unknown }> = {
207
+ path: '/some/forge.json',
208
+ zodErrors: [],
209
+ };
210
+ expect(_detail.path).toBe('/some/forge.json');
211
+ });
212
+
213
+ it('forge-unknown-field detail contains path + fieldNames', () => {
214
+ const _detail: Extract<GameProjectErrorDetail, { path: string; fieldNames: string[] }> = {
215
+ path: '/some/forge.json',
216
+ fieldNames: ['scenes'],
217
+ };
218
+ expect(_detail.fieldNames).toEqual(['scenes']);
219
+ });
220
+
221
+ it('forge-guid-malformed detail contains field + rawInput', () => {
222
+ const _detail: Extract<GameProjectErrorDetail, { field: string; rawInput: string }> = {
223
+ field: 'defaultScene',
224
+ rawInput: 'rogue-encampment',
225
+ };
226
+ expect(_detail.rawInput).toBe('rogue-encampment');
227
+ });
228
+
229
+ it('forge-scene-unresolved detail contains guid:string', () => {
230
+ const _detail: Extract<GameProjectErrorDetail, { guid: string }> = {
231
+ guid: '15acc839-d847-527c-8284-bfb36d7c50de',
232
+ };
233
+ expect(_detail.guid).toBe('15acc839-d847-527c-8284-bfb36d7c50de');
234
+ });
235
+ });
236
+
237
+ // ── error message ───────────────────────────────────────────────────────────
238
+ describe('GameProjectError — message', () => {
239
+ it('is an instance of Error', () => {
240
+ const err = makeErr();
241
+ expect(err).toBeInstanceOf(Error);
242
+ });
243
+
244
+ it('has name GameProjectError', () => {
245
+ const err = makeErr();
246
+ expect(err.name).toBe('GameProjectError');
247
+ });
248
+
249
+ it('message contains code for human readability', () => {
250
+ const err = makeErr();
251
+ expect(err.message).toContain('GameProjectError');
252
+ expect(err.message).toContain('forge-missing');
253
+ });
254
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "test3",
3
+ "name": "test3",
4
+ "schemaVersion": "1.0.0",
5
+ "entry": "main.ts",
6
+ "physics": "3d"
7
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "cow-survivor",
3
+ "name": "Cow-Level Survivor",
4
+ "schemaVersion": "1.0.0",
5
+ "entry": "main.ts",
6
+ "physics": "3d",
7
+ "defaultScene": "7b4d43d4-5b19-5903-8966-f89671d21565"
8
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "fps",
3
+ "name": "Sector Strike",
4
+ "schemaVersion": "1.0.0",
5
+ "entry": "main.ts",
6
+ "pointerLock": true
7
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "id": "hellforge",
3
+ "name": "ForgeaX: Hellforge",
4
+ "schemaVersion": "1.0.0",
5
+ "entry": "main.ts",
6
+ "physics": "3d",
7
+ "pointerLock": true,
8
+ "defaultScene": "15acc839-d847-527c-8284-bfb36d7c50de",
9
+ "preview": {
10
+ "skin": {
11
+ "sceneGuid": "5e3028dd-ddf6-4104-86d9-318d3e8fb5a6",
12
+ "clipGuids": [
13
+ "c530adf2-8de6-486a-afaa-9af3a6e6dfd1",
14
+ "f9355148-5ddc-45a4-80d4-ef80fce559b0",
15
+ "9bb05e7d-6156-424f-8a20-f80373507f65",
16
+ "7faedc58-49cd-4fc9-93b7-66eca1b79674",
17
+ "ca6e7f12-8e1a-4b3c-9d50-2a4f1b8c6d04"
18
+ ],
19
+ "clipDefault": "idle",
20
+ "scale": 1,
21
+ "pos": [0, 0, 0]
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "shoot-opt",
3
+ "name": "shoot-opt",
4
+ "schemaVersion": "1.0.0",
5
+ "entry": "src/main.ts"
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "spin-cube",
3
+ "name": "spin-cube",
4
+ "schemaVersion": "1.0.0",
5
+ "entry": "main.ts"
6
+ }
@@ -0,0 +1,364 @@
1
+ // loader.test.ts — w4: loadGameProject 5 return paths + sync companion tests
2
+ import { describe, expect, it } from 'vitest';
3
+ import { loadGameProject, loadGameProjectSync, validateGameProject } from '../loader.js';
4
+
5
+ // ── helpers ─────────────────────────────────────────────────────────────────
6
+ function makeRead(content: string): (path: string) => Promise<string> {
7
+ return async (_path: string) => content;
8
+ }
9
+
10
+ function makeReadThrow(err: Error): (path: string) => Promise<string> {
11
+ return async (_path: string) => {
12
+ throw err;
13
+ };
14
+ }
15
+
16
+ function makeReaderError(code: string, message = `reader refusal: ${code}`): Error {
17
+ const error = new Error(message);
18
+ Object.assign(error, { code });
19
+ return error;
20
+ }
21
+
22
+ const VALID_FORGE_JSON = JSON.stringify({
23
+ id: 'test-game',
24
+ name: 'Test Game',
25
+ schemaVersion: '1.0.0',
26
+ });
27
+
28
+ const VALID_WITH_DEFAULT_SCENE = JSON.stringify({
29
+ id: 'test-game',
30
+ name: 'Test Game',
31
+ schemaVersion: '1.0.0',
32
+ defaultScene: '15acc839-d847-527c-8284-bfb36d7c50de',
33
+ });
34
+
35
+ const INVALID_JSON = '{id: broken';
36
+
37
+ const WITH_SCENES_ARRAY = JSON.stringify({
38
+ id: 'has-scenes',
39
+ name: 'Has Scenes',
40
+ schemaVersion: '1.0.0',
41
+ scenes: [{ id: 'l1', name: 'Level 1', pack: 'scenes/level1.pack.json' }],
42
+ });
43
+
44
+ const WITH_BAD_GUID = JSON.stringify({
45
+ id: 'bad-guid',
46
+ name: 'Bad GUID',
47
+ schemaVersion: '1.0.0',
48
+ defaultScene: 'not-a-guid',
49
+ });
50
+
51
+ const WITHOUT_DEFAULT_SCENE = JSON.stringify({
52
+ id: 'no-default',
53
+ name: 'No Default',
54
+ schemaVersion: '1.0.0',
55
+ });
56
+
57
+ const WITH_NPC_POLICY = JSON.stringify({
58
+ id: 'npc-game',
59
+ name: 'NPC Game',
60
+ schemaVersion: '1.0.0',
61
+ npc: {
62
+ model: 'deepseek-v4-flash-openai',
63
+ maxTokens: 220,
64
+ budget: { maxCallsPerMinute: 30, maxTokensPerMinute: 120000, maxConcurrent: 4 },
65
+ },
66
+ });
67
+
68
+ // ── return path 1: forge.json missing ──────────────────────────────────────
69
+ describe('loadGameProject — return path 1: file missing', () => {
70
+ it('returns {ok:false} with code forge-missing when read throws', async () => {
71
+ const notFound = new Error('ENOENT: no such file');
72
+ (notFound as NodeJS.ErrnoException).code = 'ENOENT';
73
+ const result = await loadGameProject(makeReadThrow(notFound));
74
+ expect(result.ok).toBe(false);
75
+ if (!result.ok) {
76
+ expect(result.error.code).toBe('forge-missing');
77
+ }
78
+ });
79
+
80
+ it('preserves forge-missing only for ENOENT', async () => {
81
+ const result = await loadGameProject(makeReadThrow(makeReaderError('EACCES')));
82
+ expect(result.ok).toBe(false);
83
+ if (!result.ok) {
84
+ expect(result.error.code).toBe('forge-read-failed');
85
+ expect(result.error.detail).toEqual({ path: 'forge.json', cause: 'permission-denied' });
86
+ expect(result.error.message).not.toContain('reader refusal');
87
+ }
88
+ });
89
+
90
+ it.each([
91
+ ['EPERM', 'permission-denied'],
92
+ ['EAGAIN', 'transient'],
93
+ ['ETIMEDOUT', 'transient'],
94
+ ['EUNKNOWN', 'unknown'],
95
+ ] as const)('maps %s to bounded cause %s', async (code, cause) => {
96
+ const result = await loadGameProject(
97
+ makeReadThrow(makeReaderError(code, 'secret reader source')),
98
+ );
99
+ expect(result.ok).toBe(false);
100
+ if (!result.ok) {
101
+ expect(result.error.code).toBe('forge-read-failed');
102
+ expect(result.error.detail).toEqual({ path: 'forge.json', cause });
103
+ expect(result.error.message).not.toContain('secret reader source');
104
+ }
105
+ });
106
+
107
+ it('retries a repaired injected reader in the same process', async () => {
108
+ let refused = true;
109
+ const reads: string[] = [];
110
+ const read = async (path: string): Promise<string> => {
111
+ reads.push(path);
112
+ if (refused) {
113
+ throw makeReaderError('EAGAIN', 'temporary refusal');
114
+ }
115
+ return VALID_FORGE_JSON;
116
+ };
117
+
118
+ const first = await loadGameProject(read);
119
+ expect(first).toMatchObject({
120
+ ok: false,
121
+ error: { code: 'forge-read-failed', detail: { path: 'forge.json', cause: 'transient' } },
122
+ });
123
+
124
+ refused = false;
125
+ const second = await loadGameProject(read);
126
+ expect(second).toEqual({ ok: true, value: JSON.parse(VALID_FORGE_JSON) });
127
+ expect(reads).toEqual(['forge.json', 'forge.json']);
128
+ });
129
+ });
130
+
131
+ // ── return path 2: invalid JSON ─────────────────────────────────────────────
132
+ describe('loadGameProject — return path 2: parse failed', () => {
133
+ it('returns {ok:false} with code forge-parse-failed for invalid JSON', async () => {
134
+ const result = await loadGameProject(makeRead(INVALID_JSON));
135
+ expect(result.ok).toBe(false);
136
+ if (!result.ok) {
137
+ expect(result.error.code).toBe('forge-parse-failed');
138
+ }
139
+ });
140
+ });
141
+
142
+ // ── return path 3: strict rejects unknown fields (scenes[]) ─────────────────
143
+ describe('loadGameProject — return path 3: unknown fields', () => {
144
+ it('returns {ok:false} with code forge-unknown-field when scenes[] present', async () => {
145
+ const result = await loadGameProject(makeRead(WITH_SCENES_ARRAY));
146
+ expect(result.ok).toBe(false);
147
+ if (!result.ok) {
148
+ expect(result.error.code).toBe('forge-unknown-field');
149
+ }
150
+ });
151
+ });
152
+
153
+ // ── return path 4: defaultScene GUID format invalid ─────────────────────────
154
+ describe('loadGameProject — return path 4: GUID malformed', () => {
155
+ it('returns {ok:false} with code forge-guid-malformed for bad GUID', async () => {
156
+ const result = await loadGameProject(makeRead(WITH_BAD_GUID));
157
+ expect(result.ok).toBe(false);
158
+ if (!result.ok) {
159
+ expect(result.error.code).toBe('forge-guid-malformed');
160
+ }
161
+ });
162
+ });
163
+
164
+ // ── return path 5: completely valid forge.json ──────────────────────────────
165
+ describe('loadGameProject — return path 5: valid forge.json', () => {
166
+ it('returns {ok:true} with GameProject value for valid forge.json', async () => {
167
+ const result = await loadGameProject(makeRead(VALID_FORGE_JSON));
168
+ expect(result.ok).toBe(true);
169
+ if (result.ok) {
170
+ expect(result.value).toBeDefined();
171
+ expect(result.value.id).toBe('test-game');
172
+ expect(result.value.name).toBe('Test Game');
173
+ expect(result.value.schemaVersion).toBe('1.0.0');
174
+ }
175
+ });
176
+
177
+ it('returns {ok:true} for valid forge.json with defaultScene', async () => {
178
+ const result = await loadGameProject(makeRead(VALID_WITH_DEFAULT_SCENE));
179
+ expect(result.ok).toBe(true);
180
+ if (result.ok) {
181
+ expect(result.value.defaultScene).toBeDefined();
182
+ }
183
+ });
184
+
185
+ it('returns {ok:true} when defaultScene is absent (optional)', async () => {
186
+ const result = await loadGameProject(makeRead(WITHOUT_DEFAULT_SCENE));
187
+ expect(result.ok).toBe(true);
188
+ if (result.ok) {
189
+ expect(result.value.id).toBe('no-default');
190
+ expect(result.value.defaultScene).toBeUndefined();
191
+ }
192
+ });
193
+
194
+ it('accepts the Digital Life NPC policy in forge.json', async () => {
195
+ const result = await loadGameProject(makeRead(WITH_NPC_POLICY));
196
+ expect(result.ok).toBe(true);
197
+ if (result.ok) {
198
+ expect(result.value.npc?.model).toBe('deepseek-v4-flash-openai');
199
+ expect(result.value.npc?.budget?.maxConcurrent).toBe(4);
200
+ }
201
+ });
202
+
203
+ it('is idempotent — same read returns same result', async () => {
204
+ const read = makeRead(VALID_FORGE_JSON);
205
+ const r1 = await loadGameProject(read);
206
+ const r2 = await loadGameProject(read);
207
+ expect(r1.ok).toBe(r2.ok);
208
+ if (r1.ok && r2.ok) {
209
+ expect(r1.value.id).toBe(r2.value.id);
210
+ }
211
+ });
212
+
213
+ it('does NOT call resolveGuid internally', async () => {
214
+ // loadGameProject only accepts `read` — no resolveGuid in signature
215
+ // Signature-level verification: the function is callable with just read
216
+ const read = makeRead(VALID_FORGE_JSON);
217
+ const result = await loadGameProject(read);
218
+ expect(result).toBeDefined();
219
+ });
220
+ });
221
+
222
+ // ── missing schemaVersion → schema-invalid ──────────────────────────────────
223
+ describe('loadGameProject — missing required fields', () => {
224
+ it('returns {ok:false} when schemaVersion is missing', async () => {
225
+ const noSchemaVersion = JSON.stringify({ id: 'test', name: 'Test' });
226
+ const result = await loadGameProject(makeRead(noSchemaVersion));
227
+ expect(result.ok).toBe(false);
228
+ if (!result.ok) {
229
+ expect(result.error.code).toBe('forge-schema-invalid');
230
+ }
231
+ });
232
+ });
233
+
234
+ // ── validateGameProject: sync pure core ─────────────────────────────────────
235
+ function makeSyncRead(content: string): (path: string) => string {
236
+ return (_path: string) => content;
237
+ }
238
+
239
+ function makeSyncReadThrow(err: Error): (path: string) => string {
240
+ return (_path: string) => {
241
+ throw err;
242
+ };
243
+ }
244
+
245
+ describe('validateGameProject — sync pure core', () => {
246
+ it('returns {ok:true} for valid forge.json', () => {
247
+ const result = validateGameProject(VALID_FORGE_JSON);
248
+ expect(result.ok).toBe(true);
249
+ if (result.ok) {
250
+ expect(result.value.id).toBe('test-game');
251
+ expect(result.value.name).toBe('Test Game');
252
+ }
253
+ });
254
+
255
+ it('returns {ok:false, code:forge-parse-failed} for invalid JSON', () => {
256
+ const result = validateGameProject(INVALID_JSON);
257
+ expect(result.ok).toBe(false);
258
+ if (!result.ok) {
259
+ expect(result.error.code).toBe('forge-parse-failed');
260
+ }
261
+ });
262
+
263
+ it('returns {ok:false, code:forge-unknown-field} when scenes[] present', () => {
264
+ const result = validateGameProject(WITH_SCENES_ARRAY);
265
+ expect(result.ok).toBe(false);
266
+ if (!result.ok) {
267
+ expect(result.error.code).toBe('forge-unknown-field');
268
+ }
269
+ });
270
+
271
+ it('returns {ok:false, code:forge-guid-malformed} for bad GUID', () => {
272
+ const result = validateGameProject(WITH_BAD_GUID);
273
+ expect(result.ok).toBe(false);
274
+ if (!result.ok) {
275
+ expect(result.error.code).toBe('forge-guid-malformed');
276
+ }
277
+ });
278
+
279
+ it('returns {ok:false, code:forge-schema-invalid} when schemaVersion missing', () => {
280
+ const noSchemaVersion = JSON.stringify({ id: 'test', name: 'Test' });
281
+ const result = validateGameProject(noSchemaVersion);
282
+ expect(result.ok).toBe(false);
283
+ if (!result.ok) {
284
+ expect(result.error.code).toBe('forge-schema-invalid');
285
+ }
286
+ });
287
+ });
288
+
289
+ // ── loadGameProjectSync: sync companion ─────────────────────────────────────
290
+ describe('loadGameProjectSync — sync companion', () => {
291
+ it('returns {ok:true} for valid forge.json', () => {
292
+ const result = loadGameProjectSync(makeSyncRead(VALID_FORGE_JSON));
293
+ expect(result.ok).toBe(true);
294
+ if (result.ok) {
295
+ expect(result.value.id).toBe('test-game');
296
+ expect(result.value.name).toBe('Test Game');
297
+ }
298
+ });
299
+
300
+ it('returns {ok:false, code:forge-missing} when read throws', () => {
301
+ const notFound = new Error('ENOENT: no such file');
302
+ (notFound as NodeJS.ErrnoException).code = 'ENOENT';
303
+ const result = loadGameProjectSync(makeSyncReadThrow(notFound));
304
+ expect(result.ok).toBe(false);
305
+ if (!result.ok) {
306
+ expect(result.error.code).toBe('forge-missing');
307
+ }
308
+ });
309
+
310
+ it.each([
311
+ ['EACCES', 'permission-denied'],
312
+ ['EAGAIN', 'transient'],
313
+ ['EUNKNOWN', 'unknown'],
314
+ ] as const)('maps sync %s to bounded cause %s', (code, cause) => {
315
+ const result = loadGameProjectSync(
316
+ makeSyncReadThrow(makeReaderError(code, 'secret sync source')),
317
+ );
318
+ expect(result.ok).toBe(false);
319
+ if (!result.ok) {
320
+ expect(result.error.code).toBe('forge-read-failed');
321
+ expect(result.error.detail).toEqual({ path: 'forge.json', cause });
322
+ expect(result.error.message).not.toContain('secret sync source');
323
+ }
324
+ });
325
+
326
+ it('retries a repaired sync reader in the same process', () => {
327
+ let refused = true;
328
+ const reads: string[] = [];
329
+ const read = (path: string): string => {
330
+ reads.push(path);
331
+ if (refused) {
332
+ throw makeReaderError('EAGAIN', 'temporary sync refusal');
333
+ }
334
+ return VALID_FORGE_JSON;
335
+ };
336
+
337
+ const first = loadGameProjectSync(read);
338
+ expect(first).toMatchObject({
339
+ ok: false,
340
+ error: { code: 'forge-read-failed', detail: { path: 'forge.json', cause: 'transient' } },
341
+ });
342
+
343
+ refused = false;
344
+ const second = loadGameProjectSync(read);
345
+ expect(second).toEqual({ ok: true, value: JSON.parse(VALID_FORGE_JSON) });
346
+ expect(reads).toEqual(['forge.json', 'forge.json']);
347
+ });
348
+
349
+ it('returns {ok:false, code:forge-unknown-field} when scenes[] present', () => {
350
+ const result = loadGameProjectSync(makeSyncRead(WITH_SCENES_ARRAY));
351
+ expect(result.ok).toBe(false);
352
+ if (!result.ok) {
353
+ expect(result.error.code).toBe('forge-unknown-field');
354
+ }
355
+ });
356
+
357
+ it('returns {ok:false, code:forge-guid-malformed} for bad GUID', () => {
358
+ const result = loadGameProjectSync(makeSyncRead(WITH_BAD_GUID));
359
+ expect(result.ok).toBe(false);
360
+ if (!result.ok) {
361
+ expect(result.error.code).toBe('forge-guid-malformed');
362
+ }
363
+ });
364
+ });