@forgeax/engine-project 0.1.21 → 0.1.23
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/README.md +8 -13
- package/dist/__tests__/errors-v2.test-d.d.ts +2 -0
- package/dist/__tests__/errors-v2.test-d.d.ts.map +1 -0
- package/dist/__tests__/schema-v2.test.d.ts +2 -0
- package/dist/__tests__/schema-v2.test.d.ts.map +1 -0
- package/dist/index.mjs +10 -48
- package/dist/index.mjs.map +1 -1
- package/dist/schema.d.ts +9 -190
- package/dist/schema.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/errors-v2.test-d.ts +56 -0
- package/src/__tests__/loader.test.ts +18 -15
- package/src/__tests__/resolve.test.ts +4 -2
- package/src/__tests__/schema-v2.test.ts +130 -0
- package/src/__tests__/schema.test.ts +62 -270
- package/src/__tests__/structural.test.ts +3 -2
- package/src/loader.ts +3 -3
- package/src/schema.ts +14 -71
- package/dist/.tsbuildinfo +0 -1
- package/dist/__tests__/ac14-migration.test.d.ts +0 -2
- package/dist/__tests__/ac14-migration.test.d.ts.map +0 -1
- package/src/__tests__/ac14-migration.test.ts +0 -124
- package/src/__tests__/fixtures/games/cow-level/forge.json +0 -7
- package/src/__tests__/fixtures/games/cow-survivor/forge.json +0 -8
- package/src/__tests__/fixtures/games/fps/forge.json +0 -7
- package/src/__tests__/fixtures/games/hellforge/forge.json +0 -24
- package/src/__tests__/fixtures/games/shoot-opt/forge.json +0 -6
- package/src/__tests__/fixtures/games/spin-cube/forge.json +0 -6
|
@@ -16,13 +16,15 @@ function makeReadThrow(err: Error): (path: string) => Promise<string> {
|
|
|
16
16
|
const VALID_FORGE_JSON = JSON.stringify({
|
|
17
17
|
id: 'test-game',
|
|
18
18
|
name: 'Test Game',
|
|
19
|
-
schemaVersion: '
|
|
19
|
+
schemaVersion: '2.0.0',
|
|
20
|
+
plugins: [],
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
const VALID_WITH_DEFAULT_SCENE = JSON.stringify({
|
|
23
24
|
id: 'test-game',
|
|
24
25
|
name: 'Test Game',
|
|
25
|
-
schemaVersion: '
|
|
26
|
+
schemaVersion: '2.0.0',
|
|
27
|
+
plugins: [],
|
|
26
28
|
defaultScene: '15acc839-d847-527c-8284-bfb36d7c50de',
|
|
27
29
|
});
|
|
28
30
|
|
|
@@ -31,27 +33,31 @@ const INVALID_JSON = '{id: broken';
|
|
|
31
33
|
const WITH_SCENES_ARRAY = JSON.stringify({
|
|
32
34
|
id: 'has-scenes',
|
|
33
35
|
name: 'Has Scenes',
|
|
34
|
-
schemaVersion: '
|
|
36
|
+
schemaVersion: '2.0.0',
|
|
37
|
+
plugins: [],
|
|
35
38
|
scenes: [{ id: 'l1', name: 'Level 1', pack: 'scenes/level1.pack.json' }],
|
|
36
39
|
});
|
|
37
40
|
|
|
38
41
|
const WITH_BAD_GUID = JSON.stringify({
|
|
39
42
|
id: 'bad-guid',
|
|
40
43
|
name: 'Bad GUID',
|
|
41
|
-
schemaVersion: '
|
|
44
|
+
schemaVersion: '2.0.0',
|
|
45
|
+
plugins: [],
|
|
42
46
|
defaultScene: 'not-a-guid',
|
|
43
47
|
});
|
|
44
48
|
|
|
45
49
|
const WITHOUT_DEFAULT_SCENE = JSON.stringify({
|
|
46
50
|
id: 'no-default',
|
|
47
51
|
name: 'No Default',
|
|
48
|
-
schemaVersion: '
|
|
52
|
+
schemaVersion: '2.0.0',
|
|
53
|
+
plugins: [],
|
|
49
54
|
});
|
|
50
55
|
|
|
51
|
-
const
|
|
56
|
+
const WITH_LEGACY_POLICY = JSON.stringify({
|
|
52
57
|
id: 'npc-game',
|
|
53
58
|
name: 'NPC Game',
|
|
54
|
-
schemaVersion: '
|
|
59
|
+
schemaVersion: '2.0.0',
|
|
60
|
+
plugins: [],
|
|
55
61
|
npc: {
|
|
56
62
|
model: 'deepseek-v4-flash-openai',
|
|
57
63
|
maxTokens: 220,
|
|
@@ -124,7 +130,7 @@ describe('loadGameProject — return path 5: valid forge.json', () => {
|
|
|
124
130
|
expect(result.value).toBeDefined();
|
|
125
131
|
expect(result.value.id).toBe('test-game');
|
|
126
132
|
expect(result.value.name).toBe('Test Game');
|
|
127
|
-
expect(result.value.schemaVersion).toBe('
|
|
133
|
+
expect(result.value.schemaVersion).toBe('2.0.0');
|
|
128
134
|
}
|
|
129
135
|
});
|
|
130
136
|
|
|
@@ -145,13 +151,10 @@ describe('loadGameProject — return path 5: valid forge.json', () => {
|
|
|
145
151
|
}
|
|
146
152
|
});
|
|
147
153
|
|
|
148
|
-
it('
|
|
149
|
-
const result = await loadGameProject(makeRead(
|
|
150
|
-
expect(result.ok).toBe(
|
|
151
|
-
if (result.ok)
|
|
152
|
-
expect(result.value.npc?.model).toBe('deepseek-v4-flash-openai');
|
|
153
|
-
expect(result.value.npc?.budget?.maxConcurrent).toBe(4);
|
|
154
|
-
}
|
|
154
|
+
it('rejects the removed NPC policy instead of dual-reading it', async () => {
|
|
155
|
+
const result = await loadGameProject(makeRead(WITH_LEGACY_POLICY));
|
|
156
|
+
expect(result.ok).toBe(false);
|
|
157
|
+
if (!result.ok) expect(result.error.code).toBe('forge-unknown-field');
|
|
155
158
|
});
|
|
156
159
|
|
|
157
160
|
it('is idempotent — same read returns same result', async () => {
|
|
@@ -10,14 +10,16 @@ function makeRead(content: string): (path: string) => Promise<string> {
|
|
|
10
10
|
const VALID_WITH_SCENE = JSON.stringify({
|
|
11
11
|
id: 'test-game',
|
|
12
12
|
name: 'Test Game',
|
|
13
|
-
schemaVersion: '
|
|
13
|
+
schemaVersion: '2.0.0',
|
|
14
|
+
plugins: [],
|
|
14
15
|
defaultScene: '15acc839-d847-527c-8284-bfb36d7c50de',
|
|
15
16
|
});
|
|
16
17
|
|
|
17
18
|
const WITHOUT_SCENE = JSON.stringify({
|
|
18
19
|
id: 'no-scene',
|
|
19
20
|
name: 'No Scene',
|
|
20
|
-
schemaVersion: '
|
|
21
|
+
schemaVersion: '2.0.0',
|
|
22
|
+
plugins: [],
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
// ── signature verification ──────────────────────────────────────────────────
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { GameProjectSchema } from '../schema.js';
|
|
4
|
+
|
|
5
|
+
const validProject = {
|
|
6
|
+
id: 'schema-v2-game',
|
|
7
|
+
name: 'Schema V2 Game',
|
|
8
|
+
schemaVersion: '2.0.0',
|
|
9
|
+
plugins: [],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('GameProjectSchema v2', () => {
|
|
13
|
+
it('requires the strict schema 2.0 project shape and allows an empty EntryTree', () => {
|
|
14
|
+
const result = GameProjectSchema.safeParse(validProject);
|
|
15
|
+
|
|
16
|
+
expect(result.success).toBe(true);
|
|
17
|
+
if (result.success) expect(result.data.plugins).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('rejects every schema version other than 2.0.0', () => {
|
|
21
|
+
for (const schemaVersion of ['1.0.0', '2.1.0', 'v2']) {
|
|
22
|
+
const result = GameProjectSchema.safeParse({ ...validProject, schemaVersion });
|
|
23
|
+
expect(result.success, schemaVersion).toBe(false);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('requires plugins as the only installable capability root', () => {
|
|
28
|
+
const { plugins: _plugins, ...withoutPlugins } = validProject;
|
|
29
|
+
const result = GameProjectSchema.safeParse(withoutPlugins);
|
|
30
|
+
|
|
31
|
+
expect(result.success).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects legacy root fields instead of preserving a dual manifest path', () => {
|
|
35
|
+
const legacyFields = [
|
|
36
|
+
'entry',
|
|
37
|
+
'executionEntry',
|
|
38
|
+
'physics',
|
|
39
|
+
'pointerLock',
|
|
40
|
+
'input',
|
|
41
|
+
'preview',
|
|
42
|
+
'npc',
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
for (const field of legacyFields) {
|
|
46
|
+
const result = GameProjectSchema.safeParse({
|
|
47
|
+
...validProject,
|
|
48
|
+
[field]: field === 'pointerLock' ? true : './legacy.ts',
|
|
49
|
+
});
|
|
50
|
+
expect(result.success, field).toBe(false);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('accepts enabled, disabled, and realm-inherited Entry states', () => {
|
|
55
|
+
const result = GameProjectSchema.safeParse({
|
|
56
|
+
...validProject,
|
|
57
|
+
plugins: [
|
|
58
|
+
{ id: 'enabled', name: './enabled.plugin.ts', realm: 'engine' },
|
|
59
|
+
{ id: 'disabled', name: './disabled.plugin.ts', disabled: true, realm: 'engine' },
|
|
60
|
+
{ id: 'inherited', name: './inherited.plugin.ts' },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects nullable disabled state because absence means enabled', () => {
|
|
68
|
+
const result = GameProjectSchema.safeParse({
|
|
69
|
+
...validProject,
|
|
70
|
+
plugins: [{ id: 'nullable', name: './nullable.plugin.ts', disabled: null }],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.success).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('accepts a Group root with nested children and inherited realm', () => {
|
|
77
|
+
const result = GameProjectSchema.safeParse({
|
|
78
|
+
...validProject,
|
|
79
|
+
plugins: [
|
|
80
|
+
{
|
|
81
|
+
id: 'game',
|
|
82
|
+
name: 'cordis:group',
|
|
83
|
+
group: true,
|
|
84
|
+
realm: 'engine',
|
|
85
|
+
config: [
|
|
86
|
+
{ id: 'world', name: './world.plugin.ts' },
|
|
87
|
+
{ id: 'player', name: './player.plugin.ts', inject: ['world'] },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.success).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects a Group whose config is not an EntryTree', () => {
|
|
97
|
+
const result = GameProjectSchema.safeParse({
|
|
98
|
+
...validProject,
|
|
99
|
+
plugins: [{ id: 'invalid-group', name: 'cordis:group', group: true, config: {} }],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.success).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects duplicate Entry ids across a Group ownership path', () => {
|
|
106
|
+
const result = GameProjectSchema.safeParse({
|
|
107
|
+
...validProject,
|
|
108
|
+
plugins: [
|
|
109
|
+
{ id: 'same', name: './root.plugin.ts' },
|
|
110
|
+
{
|
|
111
|
+
id: 'group',
|
|
112
|
+
name: 'cordis:group',
|
|
113
|
+
group: true,
|
|
114
|
+
config: [{ id: 'same', name: './child.plugin.ts' }],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result.success).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects an unknown physical realm before module activation', () => {
|
|
123
|
+
const result = GameProjectSchema.safeParse({
|
|
124
|
+
...validProject,
|
|
125
|
+
plugins: [{ id: 'unknown', name: './unknown.plugin.ts', realm: 'worker' }],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result.success).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -1,99 +1,22 @@
|
|
|
1
|
-
// schema.test.ts — w2: GameProjectSchema + GuidString refinement tests
|
|
2
1
|
import { describe, expect, it } from 'vitest';
|
|
3
2
|
import { GameProjectSchema, GuidString } from '../schema.js';
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
});
|
|
4
|
+
const base = {
|
|
5
|
+
id: 'test-game',
|
|
6
|
+
name: 'Test Game',
|
|
7
|
+
schemaVersion: '2.0.0',
|
|
8
|
+
plugins: [],
|
|
9
|
+
};
|
|
46
10
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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);
|
|
11
|
+
describe('GameProjectSchema schema 2.0', () => {
|
|
12
|
+
it('accepts a minimal strict manifest', () => {
|
|
13
|
+
expect(GameProjectSchema.safeParse(base).success).toBe(true);
|
|
78
14
|
});
|
|
79
15
|
|
|
80
|
-
it('accepts
|
|
16
|
+
it('accepts defaultScene and a nested native Entry tree', () => {
|
|
81
17
|
const result = GameProjectSchema.safeParse({
|
|
82
|
-
|
|
83
|
-
|
|
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',
|
|
18
|
+
...base,
|
|
19
|
+
defaultScene: '15acc839-d847-527c-8284-bfb36d7c50de',
|
|
97
20
|
plugins: [
|
|
98
21
|
{
|
|
99
22
|
id: 'engine-features',
|
|
@@ -114,11 +37,49 @@ describe('GameProjectSchema — acceptance', () => {
|
|
|
114
37
|
expect(result.success).toBe(true);
|
|
115
38
|
});
|
|
116
39
|
|
|
117
|
-
it('rejects
|
|
40
|
+
it('rejects every schema version except 2.0.0', () => {
|
|
41
|
+
for (const schemaVersion of ['1.0.0', '2.1.0', 'v2']) {
|
|
42
|
+
expect(GameProjectSchema.safeParse({ ...base, schemaVersion }).success).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('requires plugins and rejects nullable entry controls', () => {
|
|
47
|
+
const { plugins: _plugins, ...withoutPlugins } = base;
|
|
48
|
+
expect(GameProjectSchema.safeParse(withoutPlugins).success).toBe(false);
|
|
49
|
+
expect(GameProjectSchema.safeParse({ ...base, disabled: null }).success).toBe(false);
|
|
50
|
+
expect(
|
|
51
|
+
GameProjectSchema.safeParse({
|
|
52
|
+
...base,
|
|
53
|
+
plugins: [{ id: 'nullable', name: './x', disabled: null }],
|
|
54
|
+
}).success,
|
|
55
|
+
).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects every legacy root field without a compatibility parser', () => {
|
|
59
|
+
for (const field of [
|
|
60
|
+
'entry',
|
|
61
|
+
'executionEntry',
|
|
62
|
+
'physics',
|
|
63
|
+
'pointerLock',
|
|
64
|
+
'input',
|
|
65
|
+
'preview',
|
|
66
|
+
'npc',
|
|
67
|
+
]) {
|
|
68
|
+
expect(GameProjectSchema.safeParse({ ...base, [field]: 'legacy' }).success).toBe(false);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects unknown top-level fields', () => {
|
|
73
|
+
const result = GameProjectSchema.safeParse({ ...base, scenes: [] });
|
|
74
|
+
expect(result.success).toBe(false);
|
|
75
|
+
if (!result.success) {
|
|
76
|
+
expect(result.error.issues.some((issue) => issue.code === 'unrecognized_keys')).toBe(true);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects duplicate plugin ids across one Entry tree', () => {
|
|
118
81
|
const result = GameProjectSchema.safeParse({
|
|
119
|
-
|
|
120
|
-
name: 'Duplicate Plugins',
|
|
121
|
-
schemaVersion: '1.0.0',
|
|
82
|
+
...base,
|
|
122
83
|
plugins: [
|
|
123
84
|
{ id: 'same', name: './one.ts' },
|
|
124
85
|
{
|
|
@@ -133,185 +94,16 @@ describe('GameProjectSchema — acceptance', () => {
|
|
|
133
94
|
});
|
|
134
95
|
});
|
|
135
96
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
expect(
|
|
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);
|
|
97
|
+
describe('GuidString', () => {
|
|
98
|
+
it('accepts UUID values used by scene assets', () => {
|
|
99
|
+
expect(GuidString.safeParse('d953a1db-483a-4b7d-8b71-b8f144488c48').success).toBe(true);
|
|
100
|
+
expect(GuidString.safeParse('15acc839-d847-527c-8284-bfb36d7c50de').success).toBe(true);
|
|
101
|
+
expect(GuidString.safeParse('7B4D43D4-5B19-5903-8966-F89671D21565').success).toBe(true);
|
|
166
102
|
});
|
|
167
103
|
|
|
168
|
-
it('rejects
|
|
169
|
-
const
|
|
170
|
-
|
|
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);
|
|
104
|
+
it('rejects malformed or non-UUID strings', () => {
|
|
105
|
+
for (const value of ['', 'not-a-guid', 'rogue-encampment']) {
|
|
106
|
+
expect(GuidString.safeParse(value).success).toBe(false);
|
|
187
107
|
}
|
|
188
108
|
});
|
|
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
109
|
});
|
|
@@ -64,7 +64,8 @@ describe('AC-02: exports accessible', () => {
|
|
|
64
64
|
const result = mod.GameProjectSchema.safeParse({
|
|
65
65
|
id: 'test',
|
|
66
66
|
name: 'Test',
|
|
67
|
-
schemaVersion: '
|
|
67
|
+
schemaVersion: '2.0.0',
|
|
68
|
+
plugins: [],
|
|
68
69
|
});
|
|
69
70
|
expect(result.success).toBe(true);
|
|
70
71
|
if (result.success) {
|
|
@@ -72,7 +73,7 @@ describe('AC-02: exports accessible', () => {
|
|
|
72
73
|
const gp = result.data;
|
|
73
74
|
expect(gp.id).toBe('test');
|
|
74
75
|
expect(gp.name).toBe('Test');
|
|
75
|
-
expect(gp.schemaVersion).toBe('
|
|
76
|
+
expect(gp.schemaVersion).toBe('2.0.0');
|
|
76
77
|
}
|
|
77
78
|
});
|
|
78
79
|
|
package/src/loader.ts
CHANGED
|
@@ -114,7 +114,7 @@ export function validateGameProject(raw: string): GuidResult<GameProject, GamePr
|
|
|
114
114
|
return err(
|
|
115
115
|
'forge-schema-invalid',
|
|
116
116
|
'forge.json to satisfy GameProjectSchema',
|
|
117
|
-
'check forge.json against GameProjectSchema (z.infer for types); required fields: id, name, schemaVersion
|
|
117
|
+
'check forge.json against GameProjectSchema (z.infer for types); required fields: id, name, schemaVersion=2.0.0, and plugins',
|
|
118
118
|
{ path: FORGE_JSON, zodErrors },
|
|
119
119
|
);
|
|
120
120
|
}
|
|
@@ -161,7 +161,7 @@ export async function loadGameProject(
|
|
|
161
161
|
return err(
|
|
162
162
|
'forge-missing',
|
|
163
163
|
'forge.json file to exist at the game root',
|
|
164
|
-
'verify the game directory contains forge.json; if this is a new game, scaffold via the Studio UI or create a forge.json with id, name, schemaVersion
|
|
164
|
+
'verify the game directory contains forge.json; if this is a new game, scaffold via the Studio UI or create a schema 2.0 forge.json with id, name, schemaVersion=2.0.0, and plugins',
|
|
165
165
|
{ path: FORGE_JSON },
|
|
166
166
|
);
|
|
167
167
|
}
|
|
@@ -190,7 +190,7 @@ export function loadGameProjectSync(
|
|
|
190
190
|
return err(
|
|
191
191
|
'forge-missing',
|
|
192
192
|
'forge.json file to exist at the game root',
|
|
193
|
-
'verify the game directory contains forge.json; if this is a new game, scaffold via the Studio UI or create a forge.json with id, name, schemaVersion
|
|
193
|
+
'verify the game directory contains forge.json; if this is a new game, scaffold via the Studio UI or create a schema 2.0 forge.json with id, name, schemaVersion=2.0.0, and plugins',
|
|
194
194
|
{ path: FORGE_JSON },
|
|
195
195
|
);
|
|
196
196
|
}
|