@forgeax/engine-project 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +77 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/ac14-migration.test.d.ts +2 -0
- package/dist/__tests__/ac14-migration.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test-d.d.ts +2 -0
- package/dist/__tests__/errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/errors.test.d.ts +2 -0
- package/dist/__tests__/errors.test.d.ts.map +1 -0
- package/dist/__tests__/loader.test.d.ts +2 -0
- package/dist/__tests__/loader.test.d.ts.map +1 -0
- package/dist/__tests__/resolve.test.d.ts +2 -0
- package/dist/__tests__/resolve.test.d.ts.map +1 -0
- package/dist/__tests__/schema.test.d.ts +2 -0
- package/dist/__tests__/schema.test.d.ts.map +1 -0
- package/dist/__tests__/structural.test.d.ts +2 -0
- package/dist/__tests__/structural.test.d.ts.map +1 -0
- package/dist/errors.d.ts +76 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +244 -0
- package/dist/index.mjs.map +1 -0
- package/dist/loader.d.ts +96 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/paths.d.ts +3 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/schema.d.ts +216 -0
- package/dist/schema.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/__tests__/ac14-migration.test.ts +124 -0
- package/src/__tests__/errors.test-d.ts +69 -0
- package/src/__tests__/errors.test.ts +233 -0
- package/src/__tests__/fixtures/games/cow-level/forge.json +7 -0
- package/src/__tests__/fixtures/games/cow-survivor/forge.json +8 -0
- package/src/__tests__/fixtures/games/fps/forge.json +7 -0
- package/src/__tests__/fixtures/games/hellforge/forge.json +24 -0
- package/src/__tests__/fixtures/games/shoot-opt/forge.json +6 -0
- package/src/__tests__/fixtures/games/spin-cube/forge.json +6 -0
- package/src/__tests__/loader.test.ts +279 -0
- package/src/__tests__/resolve.test.ts +116 -0
- package/src/__tests__/schema.test.ts +317 -0
- package/src/__tests__/structural.test.ts +187 -0
- package/src/errors.ts +118 -0
- package/src/index.ts +35 -0
- package/src/loader.ts +282 -0
- package/src/paths.ts +7 -0
- package/src/schema.ts +149 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// schema.test.ts — w2: GameProjectSchema + GuidString refinement tests
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { GameProjectSchema, GuidString } from '../schema.js';
|
|
4
|
+
|
|
5
|
+
// ── valid full forge.json ──────────────────────────────────────────────────
|
|
6
|
+
describe('GameProjectSchema — acceptance', () => {
|
|
7
|
+
it('accepts a valid full forge.json', () => {
|
|
8
|
+
const result = GameProjectSchema.safeParse({
|
|
9
|
+
id: 'hellforge',
|
|
10
|
+
name: 'Hellforge',
|
|
11
|
+
schemaVersion: '1.0.0',
|
|
12
|
+
defaultScene: '15acc839-d847-527c-8284-bfb36d7c50de',
|
|
13
|
+
physics: '3d',
|
|
14
|
+
pointerLock: true,
|
|
15
|
+
input: 'fps',
|
|
16
|
+
preview: {
|
|
17
|
+
skin: {
|
|
18
|
+
sceneGuid: '11111111-1111-1111-1111-111111111111',
|
|
19
|
+
clipGuids: [],
|
|
20
|
+
clipDefault: 'idle',
|
|
21
|
+
scale: 1,
|
|
22
|
+
pos: [0, 0],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
expect(result.success).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('accepts minimal forge.json with only id+name+schemaVersion', () => {
|
|
30
|
+
const result = GameProjectSchema.safeParse({
|
|
31
|
+
id: 'minimal',
|
|
32
|
+
name: 'Minimal Game',
|
|
33
|
+
schemaVersion: '1.0.0',
|
|
34
|
+
});
|
|
35
|
+
expect(result.success).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('accepts forge.json without entry (entry is optional)', () => {
|
|
39
|
+
const result = GameProjectSchema.safeParse({
|
|
40
|
+
id: 'no-entry',
|
|
41
|
+
name: 'No Entry Game',
|
|
42
|
+
schemaVersion: '1.0.0',
|
|
43
|
+
});
|
|
44
|
+
expect(result.success).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('accepts forge.json without defaultScene (defaultScene is optional)', () => {
|
|
48
|
+
const result = GameProjectSchema.safeParse({
|
|
49
|
+
id: 'no-default',
|
|
50
|
+
name: 'No Default Game',
|
|
51
|
+
schemaVersion: '1.0.0',
|
|
52
|
+
});
|
|
53
|
+
expect(result.success).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps forge.json as the engine-project manifest, not a Catalog asset', () => {
|
|
57
|
+
const result = GameProjectSchema.safeParse({
|
|
58
|
+
id: 'manifest-only',
|
|
59
|
+
name: 'Manifest Only',
|
|
60
|
+
schemaVersion: '1.0.0',
|
|
61
|
+
});
|
|
62
|
+
expect(result.success).toBe(true);
|
|
63
|
+
if (result.success) {
|
|
64
|
+
expect(result.data).not.toHaveProperty('guid');
|
|
65
|
+
expect(result.data).not.toHaveProperty('sourceKey');
|
|
66
|
+
expect(result.data).not.toHaveProperty('assets');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('accepts forge.json with entry field present', () => {
|
|
71
|
+
const result = GameProjectSchema.safeParse({
|
|
72
|
+
id: 'with-entry',
|
|
73
|
+
name: 'With Entry',
|
|
74
|
+
schemaVersion: '1.0.0',
|
|
75
|
+
entry: 'main.ts',
|
|
76
|
+
});
|
|
77
|
+
expect(result.success).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('accepts an explicit realm-local execution entry', () => {
|
|
81
|
+
const result = GameProjectSchema.safeParse({
|
|
82
|
+
id: 'worker-game',
|
|
83
|
+
name: 'Worker Game',
|
|
84
|
+
schemaVersion: '1.0.0',
|
|
85
|
+
entry: 'main.ts',
|
|
86
|
+
executionEntry: 'runtime.ts',
|
|
87
|
+
});
|
|
88
|
+
expect(result.success).toBe(true);
|
|
89
|
+
if (result.success) expect(result.data.executionEntry).toBe('runtime.ts');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('accepts a DSH-aligned plugin Entry tree with ForgeaX realm placement', () => {
|
|
93
|
+
const result = GameProjectSchema.safeParse({
|
|
94
|
+
id: 'plugin-game',
|
|
95
|
+
name: 'Plugin Game',
|
|
96
|
+
schemaVersion: '1.0.0',
|
|
97
|
+
plugins: [
|
|
98
|
+
{
|
|
99
|
+
id: 'engine-features',
|
|
100
|
+
name: 'cordis:group',
|
|
101
|
+
group: true,
|
|
102
|
+
realm: 'engine',
|
|
103
|
+
config: [
|
|
104
|
+
{
|
|
105
|
+
id: 'gameplay',
|
|
106
|
+
name: './main.ts',
|
|
107
|
+
config: { difficulty: 'hard' },
|
|
108
|
+
inject: ['world', 'renderer'],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
expect(result.success).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('rejects duplicate plugin Entry ids anywhere in one tree', () => {
|
|
118
|
+
const result = GameProjectSchema.safeParse({
|
|
119
|
+
id: 'duplicate-plugins',
|
|
120
|
+
name: 'Duplicate Plugins',
|
|
121
|
+
schemaVersion: '1.0.0',
|
|
122
|
+
plugins: [
|
|
123
|
+
{ id: 'same', name: './one.ts' },
|
|
124
|
+
{
|
|
125
|
+
id: 'group',
|
|
126
|
+
name: 'cordis:group',
|
|
127
|
+
group: true,
|
|
128
|
+
config: [{ id: 'same', name: './two.ts' }],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
expect(result.success).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── GuidString refinement ───────────────────────────────────────────────────
|
|
137
|
+
describe('GuidString refinement', () => {
|
|
138
|
+
it('accepts valid 36-char dash-form UUID v4', () => {
|
|
139
|
+
const result = GuidString.safeParse('d953a1db-483a-4b7d-8b71-b8f144488c48');
|
|
140
|
+
expect(result.success).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('accepts valid 36-char dash-form UUID v7', () => {
|
|
144
|
+
const result = GuidString.safeParse('15acc839-d847-527c-8284-bfb36d7c50de');
|
|
145
|
+
expect(result.success).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('accepts valid 36-char dash-form UUID (uppercase hex)', () => {
|
|
149
|
+
const result = GuidString.safeParse('7B4D43D4-5B19-5903-8966-F89671D21565');
|
|
150
|
+
expect(result.success).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('rejects malformed UUID string (too short)', () => {
|
|
154
|
+
const result = GuidString.safeParse('not-a-guid');
|
|
155
|
+
expect(result.success).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('rejects plain slug string (non-UUID)', () => {
|
|
159
|
+
const result = GuidString.safeParse('rogue-encampment');
|
|
160
|
+
expect(result.success).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects empty string', () => {
|
|
164
|
+
const result = GuidString.safeParse('');
|
|
165
|
+
expect(result.success).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('rejects string with only 35 chars', () => {
|
|
169
|
+
const result = GuidString.safeParse('a2345678-1234-1234-1234-12345678901');
|
|
170
|
+
expect(result.success).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── strict rejection of unknown fields ──────────────────────────────────────
|
|
175
|
+
describe('GameProjectSchema — strict rejection', () => {
|
|
176
|
+
it('rejects scenes[] as unknown field', () => {
|
|
177
|
+
const result = GameProjectSchema.safeParse({
|
|
178
|
+
id: 'has-scenes',
|
|
179
|
+
name: 'Has Scenes',
|
|
180
|
+
schemaVersion: '1.0.0',
|
|
181
|
+
scenes: [{ id: 'l1', name: 'Level 1', pack: 'scenes/level1.pack.json' }],
|
|
182
|
+
});
|
|
183
|
+
expect(result.success).toBe(false);
|
|
184
|
+
if (!result.success) {
|
|
185
|
+
// zod puts unrecognized_keys in the first issue
|
|
186
|
+
expect(result.error.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('rejects any unknown top-level field', () => {
|
|
191
|
+
const result = GameProjectSchema.safeParse({
|
|
192
|
+
id: 'extra-field',
|
|
193
|
+
name: 'Extra',
|
|
194
|
+
schemaVersion: '1.0.0',
|
|
195
|
+
unknownField: 'should not be here',
|
|
196
|
+
});
|
|
197
|
+
expect(result.success).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ── physics union (D-4: all 5 known values) ─────────────────────────────────
|
|
202
|
+
describe('GameProjectSchema — physics union', () => {
|
|
203
|
+
const physicsValues = ['3d', '2d', 'rapier-3d', 'rapier-2d', true] as const;
|
|
204
|
+
|
|
205
|
+
for (const phys of physicsValues) {
|
|
206
|
+
it(`accepts physics=${JSON.stringify(phys)}`, () => {
|
|
207
|
+
const result = GameProjectSchema.safeParse({
|
|
208
|
+
id: 'phys-test',
|
|
209
|
+
name: 'Physics Test',
|
|
210
|
+
schemaVersion: '1.0.0',
|
|
211
|
+
physics: phys,
|
|
212
|
+
});
|
|
213
|
+
expect(result.success).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it('rejects arbitrary string for physics', () => {
|
|
218
|
+
const result = GameProjectSchema.safeParse({
|
|
219
|
+
id: 'bad-physics',
|
|
220
|
+
name: 'Bad Physics',
|
|
221
|
+
schemaVersion: '1.0.0',
|
|
222
|
+
physics: 'custom-engine',
|
|
223
|
+
});
|
|
224
|
+
expect(result.success).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('rejects number for physics', () => {
|
|
228
|
+
const result = GameProjectSchema.safeParse({
|
|
229
|
+
id: 'bad-physics-num',
|
|
230
|
+
name: 'Bad Physics Num',
|
|
231
|
+
schemaVersion: '1.0.0',
|
|
232
|
+
physics: 42,
|
|
233
|
+
});
|
|
234
|
+
expect(result.success).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('accepts physics=false (boolean true/false both valid per D-4)', () => {
|
|
238
|
+
const result = GameProjectSchema.safeParse({
|
|
239
|
+
id: 'physics-false',
|
|
240
|
+
name: 'Physics False',
|
|
241
|
+
schemaVersion: '1.0.0',
|
|
242
|
+
physics: false,
|
|
243
|
+
});
|
|
244
|
+
expect(result.success).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── pointerLock boolean ─────────────────────────────────────────────────────
|
|
249
|
+
describe('GameProjectSchema — pointerLock', () => {
|
|
250
|
+
it('accepts pointerLock=false', () => {
|
|
251
|
+
const result = GameProjectSchema.safeParse({
|
|
252
|
+
id: 'pl-false',
|
|
253
|
+
name: 'PL False',
|
|
254
|
+
schemaVersion: '1.0.0',
|
|
255
|
+
pointerLock: false,
|
|
256
|
+
});
|
|
257
|
+
expect(result.success).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('rejects non-boolean pointerLock', () => {
|
|
261
|
+
const result = GameProjectSchema.safeParse({
|
|
262
|
+
id: 'pl-string',
|
|
263
|
+
name: 'PL String',
|
|
264
|
+
schemaVersion: '1.0.0',
|
|
265
|
+
pointerLock: 'true',
|
|
266
|
+
});
|
|
267
|
+
expect(result.success).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── input string ────────────────────────────────────────────────────────────
|
|
272
|
+
describe('GameProjectSchema — input', () => {
|
|
273
|
+
it('accepts input string', () => {
|
|
274
|
+
const result = GameProjectSchema.safeParse({
|
|
275
|
+
id: 'with-input',
|
|
276
|
+
name: 'With Input',
|
|
277
|
+
schemaVersion: '1.0.0',
|
|
278
|
+
input: 'fps',
|
|
279
|
+
});
|
|
280
|
+
expect(result.success).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── preview.skin nested object ──────────────────────────────────────────────
|
|
285
|
+
describe('GameProjectSchema — preview.skin', () => {
|
|
286
|
+
it('accepts full preview.skin object', () => {
|
|
287
|
+
const result = GameProjectSchema.safeParse({
|
|
288
|
+
id: 'with-preview',
|
|
289
|
+
name: 'With Preview',
|
|
290
|
+
schemaVersion: '1.0.0',
|
|
291
|
+
preview: {
|
|
292
|
+
skin: {
|
|
293
|
+
sceneGuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
|
294
|
+
clipGuids: ['bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'],
|
|
295
|
+
clipDefault: 'walk',
|
|
296
|
+
scale: 2.5,
|
|
297
|
+
pos: [100, 200, 300],
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
expect(result.success).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('accepts minimal preview.skin (sceneGuid only)', () => {
|
|
305
|
+
const result = GameProjectSchema.safeParse({
|
|
306
|
+
id: 'preview-min',
|
|
307
|
+
name: 'Preview Min',
|
|
308
|
+
schemaVersion: '1.0.0',
|
|
309
|
+
preview: {
|
|
310
|
+
skin: {
|
|
311
|
+
sceneGuid: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
expect(result.success).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// structural.test.ts — w6: Structural verification (AC-01/02/05/06)
|
|
2
|
+
//
|
|
3
|
+
// Post-implementation verification of the built package:
|
|
4
|
+
// AC-01: package directory + package.json name
|
|
5
|
+
// AC-02: four exports accessible + GameProject is z.infer-derived
|
|
6
|
+
// AC-05: zod in dependencies, z.infer used
|
|
7
|
+
// AC-06: no node:fs / fetch imports in source
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
// ── AC-01: package directory exists + package.json name ─────────────────────
|
|
14
|
+
describe('AC-01: package directory', () => {
|
|
15
|
+
it('package directory exists at packages/project/', () => {
|
|
16
|
+
const pkgDir = path.resolve(import.meta.dirname, '..', '..');
|
|
17
|
+
expect(fs.existsSync(pkgDir)).toBe(true);
|
|
18
|
+
|
|
19
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
20
|
+
expect(fs.existsSync(pkgJsonPath)).toBe(true);
|
|
21
|
+
|
|
22
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
23
|
+
expect(pkg.name).toBe('@forgeax/engine-project');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps the public package exports stable after the directory move', () => {
|
|
27
|
+
const pkgDir = path.resolve(import.meta.dirname, '..', '..');
|
|
28
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
|
|
29
|
+
expect(pkg.exports).toEqual({
|
|
30
|
+
'.': {
|
|
31
|
+
types: './dist/index.d.ts',
|
|
32
|
+
import: './dist/index.mjs',
|
|
33
|
+
},
|
|
34
|
+
'./package.json': './package.json',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ── AC-02: four exports accessible ──────────────────────────────────────────
|
|
40
|
+
describe('AC-02: exports accessible', () => {
|
|
41
|
+
it('loadGameProject is importable', async () => {
|
|
42
|
+
const mod = await import('../index.js');
|
|
43
|
+
expect(mod.loadGameProject).toBeDefined();
|
|
44
|
+
expect(typeof mod.loadGameProject).toBe('function');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('GameProjectSchema is importable', async () => {
|
|
48
|
+
const mod = await import('../index.js');
|
|
49
|
+
expect(mod.GameProjectSchema).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('FORGE_JSON is importable', async () => {
|
|
53
|
+
const mod = await import('../index.js');
|
|
54
|
+
expect(mod.FORGE_JSON).toBe('forge.json');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('GameProject type is derived from z.infer', async () => {
|
|
58
|
+
// Import the schema and check that GameProject is a z.infer type.
|
|
59
|
+
// At runtime z.infer produces nothing, but we can verify the schema exists
|
|
60
|
+
// and that the type declaration compiles (verified by typecheck step).
|
|
61
|
+
const mod = await import('../index.js');
|
|
62
|
+
expect(mod.GameProjectSchema).toBeDefined();
|
|
63
|
+
// We can create a valid object and parse it using the schema
|
|
64
|
+
const result = mod.GameProjectSchema.safeParse({
|
|
65
|
+
id: 'test',
|
|
66
|
+
name: 'Test',
|
|
67
|
+
schemaVersion: '1.0.0',
|
|
68
|
+
});
|
|
69
|
+
expect(result.success).toBe(true);
|
|
70
|
+
if (result.success) {
|
|
71
|
+
// GameProject type fields are inferrable — this object has id/name/schemaVersion
|
|
72
|
+
const gp = result.data;
|
|
73
|
+
expect(gp.id).toBe('test');
|
|
74
|
+
expect(gp.name).toBe('Test');
|
|
75
|
+
expect(gp.schemaVersion).toBe('1.0.0');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('resolveDefaultScene is importable', async () => {
|
|
80
|
+
const mod = await import('../index.js');
|
|
81
|
+
expect(mod.resolveDefaultScene).toBeDefined();
|
|
82
|
+
expect(typeof mod.resolveDefaultScene).toBe('function');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps the validation primitive owner-local', async () => {
|
|
86
|
+
const mod = await import('../index.js');
|
|
87
|
+
expect('validateGameProject' in mod).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('GameProjectError is importable', async () => {
|
|
91
|
+
const mod = await import('../index.js');
|
|
92
|
+
expect(mod.GameProjectError).toBeDefined();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── AC-05: zod in dependencies, z.infer used ────────────────────────────────
|
|
97
|
+
describe('AC-05: zod dependency', () => {
|
|
98
|
+
it('package.json deps includes zod', () => {
|
|
99
|
+
const pkgDir = path.resolve(import.meta.dirname, '..', '..');
|
|
100
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
|
|
101
|
+
expect(pkg.dependencies).toBeDefined();
|
|
102
|
+
expect(pkg.dependencies.zod).toBeDefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('schema.ts imports zod and uses z.infer', () => {
|
|
106
|
+
const schemaPath = path.resolve(import.meta.dirname, '..', 'schema.ts');
|
|
107
|
+
const content = fs.readFileSync(schemaPath, 'utf-8');
|
|
108
|
+
expect(content).toContain("from 'zod'");
|
|
109
|
+
expect(content).toContain('z.infer');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── AC-06: no node:fs / fetch in engine-project src/ ────────────────────────
|
|
114
|
+
describe('AC-06: no node:fs / fetch imports in source', () => {
|
|
115
|
+
const srcDir = path.resolve(import.meta.dirname, '..');
|
|
116
|
+
|
|
117
|
+
function grepForbiddenImports(filePath: string): string[] {
|
|
118
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
119
|
+
const hits: string[] = [];
|
|
120
|
+
|
|
121
|
+
// Only check actual imports, not comments
|
|
122
|
+
const lines = content.split('\n');
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
trimmed.includes("from 'node:fs'") ||
|
|
130
|
+
trimmed.includes('from "node:fs"') ||
|
|
131
|
+
trimmed.includes("from 'fs'") ||
|
|
132
|
+
trimmed.includes('from "fs"') ||
|
|
133
|
+
trimmed.includes('import fs ') ||
|
|
134
|
+
trimmed.includes('import * as fs ') ||
|
|
135
|
+
trimmed.includes("require('fs'") ||
|
|
136
|
+
trimmed.includes('require("fs"') ||
|
|
137
|
+
trimmed.includes("from 'node:path'") ||
|
|
138
|
+
trimmed.includes('from "node:path"') ||
|
|
139
|
+
trimmed.includes('globalThis.fetch') ||
|
|
140
|
+
trimmed.includes('window.fetch')
|
|
141
|
+
) {
|
|
142
|
+
hits.push(line);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Also check for fetch() calls that aren't from injection
|
|
147
|
+
const fetchCallPattern = /\bfetch\s*\(/;
|
|
148
|
+
if (fetchCallPattern.test(content)) {
|
|
149
|
+
hits.push('contains fetch() call');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return hits;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('source files have no node:fs imports', () => {
|
|
156
|
+
const entries = fs.readdirSync(srcDir, { recursive: true });
|
|
157
|
+
const files: string[] = [];
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (
|
|
160
|
+
typeof entry === 'string' &&
|
|
161
|
+
entry.endsWith('.ts') &&
|
|
162
|
+
!entry.includes('__tests__') &&
|
|
163
|
+
!entry.includes('.test.')
|
|
164
|
+
) {
|
|
165
|
+
files.push(path.join(srcDir, entry));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
const hits = grepForbiddenImports(file);
|
|
171
|
+
expect(
|
|
172
|
+
hits,
|
|
173
|
+
`${path.relative(srcDir, file)} has forbidden imports: ${hits.join(', ')}`,
|
|
174
|
+
).toHaveLength(0);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('loader.ts uses injection-only read (no fetch/fs)', () => {
|
|
179
|
+
const loaderPath = path.resolve(import.meta.dirname, '..', 'loader.ts');
|
|
180
|
+
const content = fs.readFileSync(loaderPath, 'utf-8');
|
|
181
|
+
// loader.ts should accept (read) as parameter — no direct fs/fetch import
|
|
182
|
+
expect(content).not.toMatch(/import\s+.*from\s+['"]node:fs['"]/);
|
|
183
|
+
expect(content).not.toMatch(/import\s+.*from\s+['"]fs['"]/);
|
|
184
|
+
// The read injection signature: `read: (path: string) => Promise<string>`
|
|
185
|
+
expect(content).toContain('read: (path: string) => Promise<string>');
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// errors.ts — GameProjectError closed error surface (D-2)
|
|
2
|
+
//
|
|
3
|
+
// Four-field surface: .code / .expected / .hint / .detail
|
|
4
|
+
// Structurally parallel to PackError (engine-pack) — same interface shape
|
|
5
|
+
// (charter P4 consistent abstraction), but own code union (D-2: pipe isolation).
|
|
6
|
+
// GUID format failure translates PackError → GameProjectError(code='forge-guid-malformed')
|
|
7
|
+
// so PackError never leaks to engine-project public surface.
|
|
8
|
+
//
|
|
9
|
+
// AI users consume via exhaustive switch on .code — no default case needed:
|
|
10
|
+
// ```ts
|
|
11
|
+
// switch (err.code) {
|
|
12
|
+
// case 'forge-missing': console.error(err.detail.path); break;
|
|
13
|
+
// case 'forge-parse-failed': console.error(err.detail.rawMessage); break;
|
|
14
|
+
// // ... all 6 cases
|
|
15
|
+
// }
|
|
16
|
+
// ```
|
|
17
|
+
|
|
18
|
+
import type { z } from 'zod';
|
|
19
|
+
|
|
20
|
+
/** forge.json file not found at the expected path. */
|
|
21
|
+
export interface ForgeMissingDetail {
|
|
22
|
+
readonly path: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** forge.json exists but contains invalid JSON. */
|
|
26
|
+
export interface ForgeParseFailedDetail {
|
|
27
|
+
readonly path: string;
|
|
28
|
+
readonly rawMessage: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** forge.json is valid JSON but fails zod schema validation (missing required fields, wrong types). */
|
|
32
|
+
export interface ForgeSchemaInvalidDetail {
|
|
33
|
+
readonly path: string;
|
|
34
|
+
/** The zod issues from `safeParse(...).error.issues` — each names the failing path + reason. */
|
|
35
|
+
readonly zodErrors: readonly z.ZodIssue[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** forge.json has .strict() rejected an unknown field (e.g. scenes[]). */
|
|
39
|
+
export interface ForgeUnknownFieldDetail {
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly fieldNames: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** defaultScene GUID string is not a valid 36-char RFC 4122 dash-form UUID.
|
|
45
|
+
* cause is the original PackError from AssetGuid.parse (for debugging), NOT part of reserved union. */
|
|
46
|
+
export interface ForgeGuidMalformedDetail {
|
|
47
|
+
readonly field: string;
|
|
48
|
+
readonly rawInput: string;
|
|
49
|
+
readonly cause?: unknown; // PackError from engine-pack, optional for debugging (D-2)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** defaultScene GUID is valid format but resolveGuid could not find it or found wrong kind. */
|
|
53
|
+
export interface ForgeSceneUnresolvedDetail {
|
|
54
|
+
readonly guid: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type GameProjectErrorDetailByCode = {
|
|
58
|
+
'forge-missing': ForgeMissingDetail;
|
|
59
|
+
'forge-parse-failed': ForgeParseFailedDetail;
|
|
60
|
+
'forge-schema-invalid': ForgeSchemaInvalidDetail;
|
|
61
|
+
'forge-unknown-field': ForgeUnknownFieldDetail;
|
|
62
|
+
'forge-guid-malformed': ForgeGuidMalformedDetail;
|
|
63
|
+
'forge-scene-unresolved': ForgeSceneUnresolvedDetail;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type GameProjectErrorCode = keyof GameProjectErrorDetailByCode;
|
|
67
|
+
export type GameProjectErrorDetail = GameProjectErrorDetailByCode[GameProjectErrorCode];
|
|
68
|
+
|
|
69
|
+
// ── constructor args ────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export type GameProjectErrorArgs<C extends GameProjectErrorCode = GameProjectErrorCode> = {
|
|
72
|
+
readonly code: C;
|
|
73
|
+
readonly expected: string;
|
|
74
|
+
readonly hint: string;
|
|
75
|
+
readonly detail: GameProjectErrorDetailByCode[C];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ── GameProjectError class (D-2) ─────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Structured error for the @forgeax/engine-project loader + resolve layer.
|
|
82
|
+
*
|
|
83
|
+
* AI users consume via exhaustive switch on .code — no default case needed.
|
|
84
|
+
* Aligns with PackError four-field surface (.code/.expected/.hint/.detail)
|
|
85
|
+
* per charter P4 (consistent abstraction) and AC-10.
|
|
86
|
+
*/
|
|
87
|
+
class GameProjectErrorClass extends Error {
|
|
88
|
+
readonly code: GameProjectErrorCode;
|
|
89
|
+
readonly expected: string;
|
|
90
|
+
readonly hint: string;
|
|
91
|
+
readonly detail: GameProjectErrorDetail;
|
|
92
|
+
|
|
93
|
+
constructor(args: GameProjectErrorArgs) {
|
|
94
|
+
super(`[GameProjectError ${args.code}] expected: ${args.expected}; hint: ${args.hint}`);
|
|
95
|
+
this.name = 'GameProjectError';
|
|
96
|
+
this.code = args.code;
|
|
97
|
+
this.expected = args.expected;
|
|
98
|
+
this.hint = args.hint;
|
|
99
|
+
this.detail = args.detail;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type GameProjectErrorVariant<C extends GameProjectErrorCode> = GameProjectErrorClass & {
|
|
104
|
+
readonly code: C;
|
|
105
|
+
readonly detail: GameProjectErrorDetailByCode[C];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type GameProjectError = {
|
|
109
|
+
[C in GameProjectErrorCode]: GameProjectErrorVariant<C>;
|
|
110
|
+
}[GameProjectErrorCode];
|
|
111
|
+
|
|
112
|
+
interface GameProjectErrorConstructor {
|
|
113
|
+
new <C extends GameProjectErrorCode>(args: GameProjectErrorArgs<C>): GameProjectErrorVariant<C>;
|
|
114
|
+
readonly prototype: GameProjectErrorClass;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const GameProjectError: GameProjectErrorConstructor =
|
|
118
|
+
GameProjectErrorClass as unknown as GameProjectErrorConstructor;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @forgeax/engine-project — packages/project/ directory, stable public identity, progressive disclosure exports (D-8, charter P1)
|
|
2
|
+
//
|
|
3
|
+
// Export order: most-used first → schema/types middle → constants/resolve last.
|
|
4
|
+
// Top: loadGameProject (primary entry point for every consumer)
|
|
5
|
+
// Middle: GameProjectSchema (self-introspection, charter P2) + GameProject type
|
|
6
|
+
// Bottom: FORGE_JSON constant + resolveDefaultScene + GameProjectError types
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
ForgeGuidMalformedDetail,
|
|
10
|
+
ForgeMissingDetail,
|
|
11
|
+
ForgeParseFailedDetail,
|
|
12
|
+
ForgeSceneUnresolvedDetail,
|
|
13
|
+
ForgeSchemaInvalidDetail,
|
|
14
|
+
ForgeUnknownFieldDetail,
|
|
15
|
+
GameProjectErrorCode,
|
|
16
|
+
GameProjectErrorDetail,
|
|
17
|
+
} from './errors.js';
|
|
18
|
+
export { GameProjectError } from './errors.js';
|
|
19
|
+
export type { ResolvedScene } from './loader.js';
|
|
20
|
+
// ── Top tier: primary entry points ──────────────────────────────────────────
|
|
21
|
+
export {
|
|
22
|
+
loadGameProject,
|
|
23
|
+
loadGameProjectSync,
|
|
24
|
+
resolveDefaultScene,
|
|
25
|
+
} from './loader.js';
|
|
26
|
+
// ── Bottom tier: constants + error types ────────────────────────────────────
|
|
27
|
+
export { FORGE_JSON } from './paths.js';
|
|
28
|
+
export type { GameProject, GameProjectPluginEntry } from './schema.js';
|
|
29
|
+
// ── Middle tier: schema self-introspection + types ──────────────────────────
|
|
30
|
+
export {
|
|
31
|
+
GameProjectPluginEntrySchema,
|
|
32
|
+
GameProjectPluginsSchema,
|
|
33
|
+
GameProjectSchema,
|
|
34
|
+
PluginRealmSchema,
|
|
35
|
+
} from './schema.js';
|