@vgai/sdk 0.4.0-canary.20260715.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.
- package/package.json +27 -0
- package/src/cinematic/capabilities-operations.ts +128 -0
- package/src/cinematic/cue-operations.ts +198 -0
- package/src/cinematic/gsap-operations.ts +126 -0
- package/src/cinematic/index.ts +59 -0
- package/src/cinematic/preview-operations.ts +279 -0
- package/src/cinematic/preview-transport.ts +244 -0
- package/src/cinematic/render-operations.ts +409 -0
- package/src/cinematic/render-transport.ts +238 -0
- package/src/cinematic/theatre-operations.ts +306 -0
- package/src/editor/camera-operations.ts +169 -0
- package/src/editor/console-operations.ts +87 -0
- package/src/editor/hierarchy-operations.ts +95 -0
- package/src/editor/index.ts +62 -0
- package/src/editor/open-operations.ts +209 -0
- package/src/editor/screenshot-operations.ts +99 -0
- package/src/editor/selection-operations.ts +144 -0
- package/src/editor/session-operations.ts +73 -0
- package/src/editor/source-location-operations.ts +106 -0
- package/src/editor/transport.ts +647 -0
- package/src/errors.ts +72 -0
- package/src/http/http-projection.ts +349 -0
- package/src/http/index.ts +11 -0
- package/src/index.ts +67 -0
- package/src/mcp/index.ts +16 -0
- package/src/mcp/mcp-projection.ts +288 -0
- package/src/operations.ts +83 -0
- package/src/play/control-operations.ts +205 -0
- package/src/play/debug-command-operations.ts +245 -0
- package/src/play/index.ts +66 -0
- package/src/play/input-operations.ts +316 -0
- package/src/play/lifecycle-operations.ts +271 -0
- package/src/play/log-operations.ts +279 -0
- package/src/play/run-ticks-operations.ts +141 -0
- package/src/play/state-operations.ts +210 -0
- package/src/play/status-operations.ts +160 -0
- package/src/play/transport.ts +728 -0
- package/src/project/asset-operations.ts +243 -0
- package/src/project/component-operations.ts +337 -0
- package/src/project/discovery-operations.ts +269 -0
- package/src/project/entity-operations.ts +366 -0
- package/src/project/index.ts +55 -0
- package/src/project/input-map-operations.ts +233 -0
- package/src/project/manifest-operations.ts +355 -0
- package/src/project/scene-operations.ts +426 -0
- package/src/project/shared.ts +299 -0
- package/src/registry.ts +285 -0
- package/src/render/capabilities/ffmpeg.ts +141 -0
- package/src/render/index.ts +15 -0
- package/src/render/render-cinematic.ts +1847 -0
- package/src/types.ts +101 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `project.inputMap.*` (B2, §8 B2 "input-map read/validate/update").
|
|
3
|
+
*
|
|
4
|
+
* REUSE: validates through the engine's real `InputMapFileSchema`
|
|
5
|
+
* (`@vgai/engine/input/schema`) — the same schema `InputManager.loadMap`
|
|
6
|
+
* parses `.inputmap.json` through at runtime. Its binding-kind set is
|
|
7
|
+
* authoritative here: an unknown `type` (not in the discriminated union) is
|
|
8
|
+
* rejected with a structured issue, while every live kind — including
|
|
9
|
+
* `mouse_move`/`gamepad_axis_pair`, un-rejected once F2 gave them real
|
|
10
|
+
* InputManager readers — validates. See that schema's jsdoc.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { InputMapFileSchema } from '@vgai/engine/input/schema';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { OperationError } from '../errors.js';
|
|
16
|
+
import { defineOperation, type OperationRegistry } from '../registry.js';
|
|
17
|
+
import {
|
|
18
|
+
BaseHashField,
|
|
19
|
+
CONFLICT_ERROR,
|
|
20
|
+
checkNotStale,
|
|
21
|
+
DryRunField,
|
|
22
|
+
FILE_NOT_FOUND_ERROR,
|
|
23
|
+
mutationResultSchema,
|
|
24
|
+
NO_PROJECT_ROOT_ERROR,
|
|
25
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
26
|
+
readFileWithHash,
|
|
27
|
+
requireProjectRoot,
|
|
28
|
+
resolveProjectPath,
|
|
29
|
+
writeFileAtomic,
|
|
30
|
+
} from './shared.js';
|
|
31
|
+
|
|
32
|
+
function formatIssues(issues: { path: PropertyKey[]; message: string }[]): string[] {
|
|
33
|
+
return issues.map((i) => `${i.path.join('.')}: ${i.message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const INPUT_MAP_INVALID_ERROR = {
|
|
37
|
+
code: 'INPUT_MAP_INVALID',
|
|
38
|
+
summary: 'The input map file/document fails schema validation.',
|
|
39
|
+
data: z.object({ issues: z.array(z.string()) }),
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// project.inputMap.read
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const ProjectInputMapReadInput = z.object({
|
|
47
|
+
inputMapPath: z
|
|
48
|
+
.string()
|
|
49
|
+
.describe(
|
|
50
|
+
'Project-relative path to an .inputmap.json file (conventionally under public/inputmaps/).',
|
|
51
|
+
),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const projectInputMapRead = defineOperation({
|
|
55
|
+
name: 'project.inputMap.read',
|
|
56
|
+
summary: 'Read + validate an .inputmap.json file.',
|
|
57
|
+
description:
|
|
58
|
+
'Reuses @vgai/engine/input/schema’s InputMapFileSchema — the same schema InputManager.loadMap parses through.',
|
|
59
|
+
input: ProjectInputMapReadInput,
|
|
60
|
+
result: z.object({
|
|
61
|
+
inputMapPath: z.string(),
|
|
62
|
+
contentHash: z
|
|
63
|
+
.string()
|
|
64
|
+
.describe('sha256 of the raw file bytes — pass as baseHash to a later update.'),
|
|
65
|
+
inputMap: z.unknown().describe('The validated input map document.'),
|
|
66
|
+
}),
|
|
67
|
+
errors: [
|
|
68
|
+
NO_PROJECT_ROOT_ERROR,
|
|
69
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
70
|
+
FILE_NOT_FOUND_ERROR,
|
|
71
|
+
INPUT_MAP_INVALID_ERROR,
|
|
72
|
+
],
|
|
73
|
+
requires: { project: true },
|
|
74
|
+
host: 'node',
|
|
75
|
+
mutates: false,
|
|
76
|
+
supportsDryRun: false,
|
|
77
|
+
permission: { risk: 'read', summary: 'Reads one input map file; no writes.' },
|
|
78
|
+
async impl(input, ctx) {
|
|
79
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
80
|
+
const absPath = resolveProjectPath(projectRoot, input.inputMapPath);
|
|
81
|
+
const { raw, hash } = readFileWithHash(absPath);
|
|
82
|
+
const json: unknown = JSON.parse(raw);
|
|
83
|
+
const parsed = InputMapFileSchema.safeParse(json);
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
throw new OperationError(
|
|
86
|
+
'INPUT_MAP_INVALID',
|
|
87
|
+
`${input.inputMapPath} failed input-map validation.`,
|
|
88
|
+
{
|
|
89
|
+
issues: formatIssues(parsed.error.issues),
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return { inputMapPath: absPath, contentHash: hash, inputMap: parsed.data };
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// project.inputMap.validate
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const ProjectInputMapValidateInput = z.object({
|
|
102
|
+
inputMapPath: z
|
|
103
|
+
.string()
|
|
104
|
+
.optional()
|
|
105
|
+
.describe('Validate this project-relative file instead of `inputMap`.'),
|
|
106
|
+
inputMap: z
|
|
107
|
+
.unknown()
|
|
108
|
+
.optional()
|
|
109
|
+
.describe('Validate this raw document instead of reading `inputMapPath`.'),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export const projectInputMapValidate = defineOperation({
|
|
113
|
+
name: 'project.inputMap.validate',
|
|
114
|
+
summary: 'Validate an input map file or raw document against the real input-map schema.',
|
|
115
|
+
description:
|
|
116
|
+
'Reuses @vgai/engine/input/schema’s InputMapFileSchema. Never throws for a malformed document — failures come back as structured issues.',
|
|
117
|
+
input: ProjectInputMapValidateInput,
|
|
118
|
+
result: z.object({ valid: z.boolean(), issues: z.array(z.string()) }),
|
|
119
|
+
errors: [
|
|
120
|
+
NO_PROJECT_ROOT_ERROR,
|
|
121
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
122
|
+
{
|
|
123
|
+
code: 'BAD_INPUT',
|
|
124
|
+
summary: 'Neither inputMapPath nor inputMap was given.',
|
|
125
|
+
data: z.object({}),
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
requires: { project: true },
|
|
129
|
+
host: 'node',
|
|
130
|
+
mutates: false,
|
|
131
|
+
supportsDryRun: false,
|
|
132
|
+
permission: {
|
|
133
|
+
risk: 'read',
|
|
134
|
+
summary: 'Reads an input map file (or validates a given document); no writes.',
|
|
135
|
+
},
|
|
136
|
+
async impl(input, ctx) {
|
|
137
|
+
let json: unknown;
|
|
138
|
+
if (input.inputMap !== undefined) {
|
|
139
|
+
json = input.inputMap;
|
|
140
|
+
} else if (input.inputMapPath !== undefined) {
|
|
141
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
142
|
+
const absPath = resolveProjectPath(projectRoot, input.inputMapPath);
|
|
143
|
+
json = JSON.parse(readFileWithHash(absPath).raw);
|
|
144
|
+
} else {
|
|
145
|
+
throw new OperationError('BAD_INPUT', 'One of inputMapPath or inputMap must be given.', {});
|
|
146
|
+
}
|
|
147
|
+
const parsed = InputMapFileSchema.safeParse(json);
|
|
148
|
+
if (!parsed.success) return { valid: false, issues: formatIssues(parsed.error.issues) };
|
|
149
|
+
return { valid: true, issues: [] };
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// project.inputMap.update — merge new/changed actions onto the existing map.
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
const ProjectInputMapUpdateInput = z.object({
|
|
158
|
+
inputMapPath: z.string().describe('Project-relative path to the .inputmap.json file to modify.'),
|
|
159
|
+
actions: z
|
|
160
|
+
.record(z.string(), z.union([z.object({ bindings: z.array(z.unknown()) }), z.null()]))
|
|
161
|
+
.describe(
|
|
162
|
+
'Actions to add/replace (by name), or set to null to delete that action. Actions not ' +
|
|
163
|
+
'mentioned are left unchanged.',
|
|
164
|
+
),
|
|
165
|
+
dryRun: DryRunField,
|
|
166
|
+
baseHash: BaseHashField,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export const projectInputMapUpdate = defineOperation({
|
|
170
|
+
name: 'project.inputMap.update',
|
|
171
|
+
summary: 'Add, replace, or delete actions in an input map — dry-run capable, conflict-checked.',
|
|
172
|
+
description:
|
|
173
|
+
'Merges `actions` onto the on-disk input map, re-validates the full merged document via InputMapFileSchema before ever writing, and refuses a stale write (CONFLICT) when baseHash no longer matches.',
|
|
174
|
+
input: ProjectInputMapUpdateInput,
|
|
175
|
+
result: mutationResultSchema(z.object({ inputMap: z.unknown() })),
|
|
176
|
+
errors: [
|
|
177
|
+
NO_PROJECT_ROOT_ERROR,
|
|
178
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
179
|
+
FILE_NOT_FOUND_ERROR,
|
|
180
|
+
INPUT_MAP_INVALID_ERROR,
|
|
181
|
+
CONFLICT_ERROR,
|
|
182
|
+
],
|
|
183
|
+
requires: { project: true },
|
|
184
|
+
host: 'node',
|
|
185
|
+
mutates: true,
|
|
186
|
+
supportsDryRun: true,
|
|
187
|
+
permission: { risk: 'write', summary: 'Writes the target input map file.' },
|
|
188
|
+
async impl(input, ctx) {
|
|
189
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
190
|
+
const absPath = resolveProjectPath(projectRoot, input.inputMapPath);
|
|
191
|
+
const { raw, hash } = readFileWithHash(absPath);
|
|
192
|
+
checkNotStale(absPath, hash, input.baseHash);
|
|
193
|
+
|
|
194
|
+
const before = JSON.parse(raw) as { version: number; actions: Record<string, unknown> };
|
|
195
|
+
const mergedActions = { ...before.actions };
|
|
196
|
+
for (const [name, value] of Object.entries(input.actions)) {
|
|
197
|
+
if (value === null) delete mergedActions[name];
|
|
198
|
+
else mergedActions[name] = value;
|
|
199
|
+
}
|
|
200
|
+
const merged = { ...before, actions: mergedActions };
|
|
201
|
+
|
|
202
|
+
const parsed = InputMapFileSchema.safeParse(merged);
|
|
203
|
+
if (!parsed.success) {
|
|
204
|
+
throw new OperationError(
|
|
205
|
+
'INPUT_MAP_INVALID',
|
|
206
|
+
'The patched input map failed schema validation.',
|
|
207
|
+
{
|
|
208
|
+
issues: formatIssues(parsed.error.issues),
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const dryRun = input.dryRun ?? false;
|
|
214
|
+
if (!dryRun) {
|
|
215
|
+
writeFileAtomic(absPath, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
dryRun,
|
|
220
|
+
written: !dryRun,
|
|
221
|
+
filesChanged: [absPath],
|
|
222
|
+
before: { inputMap: before },
|
|
223
|
+
after: { inputMap: parsed.data },
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/** Register every B2 `project.inputMap.*` operation onto `registry`. */
|
|
229
|
+
export function registerInputMapOperations(registry: OperationRegistry): void {
|
|
230
|
+
registry.register(projectInputMapRead);
|
|
231
|
+
registry.register(projectInputMapValidate);
|
|
232
|
+
registry.register(projectInputMapUpdate);
|
|
233
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `project.discover` and `project.manifest.*` (B2,
|
|
3
|
+
* docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §8 B2 "project
|
|
4
|
+
* discovery/status" + "manifest read/validate/update").
|
|
5
|
+
*
|
|
6
|
+
* REUSE, not reimplementation: `project.manifest.validate`/`.update` both
|
|
7
|
+
* validate through the real engine manifest schema/loader
|
|
8
|
+
* (`@vgai/engine/manifest/schema`'s `GameManifestSchema`,
|
|
9
|
+
* `@vgai/engine/manifest/load`'s `loadGameManifest` — the exact same
|
|
10
|
+
* cross-field checks `vgai validate`/the editor/the runtime use), never a
|
|
11
|
+
* second hand-rolled manifest shape check.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { loadGameManifest } from '@vgai/engine/manifest/load';
|
|
17
|
+
import { GameManifestSchema } from '@vgai/engine/manifest/schema';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { OperationError } from '../errors.js';
|
|
20
|
+
import { defineOperation, type OperationRegistry } from '../registry.js';
|
|
21
|
+
import {
|
|
22
|
+
BaseHashField,
|
|
23
|
+
CONFLICT_ERROR,
|
|
24
|
+
checkNotStale,
|
|
25
|
+
DryRunField,
|
|
26
|
+
FILE_NOT_FOUND_ERROR,
|
|
27
|
+
listProjectFiles,
|
|
28
|
+
mutationResultSchema,
|
|
29
|
+
NO_PROJECT_ROOT_ERROR,
|
|
30
|
+
readFileWithHash,
|
|
31
|
+
requireProjectRoot,
|
|
32
|
+
writeFileAtomic,
|
|
33
|
+
} from './shared.js';
|
|
34
|
+
|
|
35
|
+
const MANIFEST_FILENAME = 'vgai.game.json';
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// project.discover — richer, real project-native discovery (B1's
|
|
39
|
+
// `project.status` is the boolean existence stand-in this task brief named;
|
|
40
|
+
// this is the "survey the whole project" op — manifest summary plus every
|
|
41
|
+
// other B2 discovery op's own listing, gathered in one call).
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const ProjectDiscoverInput = z.object({}).describe('No input — surveys ctx.projectRoot.');
|
|
45
|
+
|
|
46
|
+
const ProjectDiscoverResult = z
|
|
47
|
+
.object({
|
|
48
|
+
projectRoot: z.string().describe('Absolute path this discovery ran against.'),
|
|
49
|
+
hasManifest: z
|
|
50
|
+
.boolean()
|
|
51
|
+
.describe('True when vgai.game.json exists directly under projectRoot.'),
|
|
52
|
+
manifestName: z
|
|
53
|
+
.string()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("The manifest's declared game name, when present/valid."),
|
|
56
|
+
rootIds: z
|
|
57
|
+
.array(z.string())
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('Adapter-root ids declared in the manifest, when present/valid.'),
|
|
60
|
+
sceneFiles: z
|
|
61
|
+
.array(z.string())
|
|
62
|
+
.describe('Every *.vscn.json found under the project (project-relative).'),
|
|
63
|
+
inputMapFiles: z
|
|
64
|
+
.array(z.string())
|
|
65
|
+
.describe('Every *.inputmap.json found under the project (project-relative).'),
|
|
66
|
+
storyFiles: z
|
|
67
|
+
.array(z.string())
|
|
68
|
+
.describe('Every *.stories.tsx / *.stories.ts found under the project.'),
|
|
69
|
+
theatreProjectFiles: z
|
|
70
|
+
.array(z.string())
|
|
71
|
+
.describe('Every *.theatre-project.json found under the project.'),
|
|
72
|
+
})
|
|
73
|
+
.describe('A one-call survey of the project at ctx.projectRoot.');
|
|
74
|
+
|
|
75
|
+
export const projectDiscover = defineOperation({
|
|
76
|
+
name: 'project.discover',
|
|
77
|
+
summary:
|
|
78
|
+
"Survey a project's manifest, scenes, input maps, stories, and Theatre project state files.",
|
|
79
|
+
description:
|
|
80
|
+
'File-native discovery (no editor process). Reads the manifest (if present, without ' +
|
|
81
|
+
'throwing on an invalid one — see result.manifestName/rootIds being absent) and walks the ' +
|
|
82
|
+
'project directory for every other B2-discoverable file family in one call.',
|
|
83
|
+
input: ProjectDiscoverInput,
|
|
84
|
+
result: ProjectDiscoverResult,
|
|
85
|
+
errors: [NO_PROJECT_ROOT_ERROR],
|
|
86
|
+
requires: { project: true },
|
|
87
|
+
host: 'node',
|
|
88
|
+
mutates: false,
|
|
89
|
+
supportsDryRun: false,
|
|
90
|
+
permission: { risk: 'read', summary: 'Reads project files; no writes.' },
|
|
91
|
+
async impl(_input, ctx) {
|
|
92
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
93
|
+
const manifestPath = join(projectRoot, MANIFEST_FILENAME);
|
|
94
|
+
const hasManifest = existsSync(manifestPath);
|
|
95
|
+
|
|
96
|
+
let manifestName: string | undefined;
|
|
97
|
+
let rootIds: string[] | undefined;
|
|
98
|
+
if (hasManifest) {
|
|
99
|
+
try {
|
|
100
|
+
const raw: unknown = JSON.parse(readFileWithHash(manifestPath).raw);
|
|
101
|
+
const resolved = loadGameManifest(raw);
|
|
102
|
+
manifestName = resolved.name;
|
|
103
|
+
rootIds = resolved.roots.map((root) => root.id);
|
|
104
|
+
} catch {
|
|
105
|
+
// Manifest present but invalid/unparseable — discovery still reports
|
|
106
|
+
// hasManifest:true; use project.manifest.validate for the structured
|
|
107
|
+
// reason why.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
projectRoot,
|
|
113
|
+
hasManifest,
|
|
114
|
+
...(manifestName !== undefined ? { manifestName } : {}),
|
|
115
|
+
...(rootIds !== undefined ? { rootIds } : {}),
|
|
116
|
+
sceneFiles: listProjectFiles(projectRoot, (p) => p.endsWith('.vscn.json')),
|
|
117
|
+
inputMapFiles: listProjectFiles(projectRoot, (p) => p.endsWith('.inputmap.json')),
|
|
118
|
+
storyFiles: listProjectFiles(
|
|
119
|
+
projectRoot,
|
|
120
|
+
(p) => p.endsWith('.stories.tsx') || p.endsWith('.stories.ts'),
|
|
121
|
+
),
|
|
122
|
+
theatreProjectFiles: listProjectFiles(projectRoot, (p) =>
|
|
123
|
+
p.endsWith('.theatre-project.json'),
|
|
124
|
+
),
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// project.manifest.read
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
const ProjectManifestReadInput = z
|
|
134
|
+
.object({})
|
|
135
|
+
.describe('No input — reads vgai.game.json under ctx.projectRoot.');
|
|
136
|
+
|
|
137
|
+
const ProjectManifestReadResult = z
|
|
138
|
+
.object({
|
|
139
|
+
path: z.string().describe('Absolute path to vgai.game.json.'),
|
|
140
|
+
contentHash: z
|
|
141
|
+
.string()
|
|
142
|
+
.describe(
|
|
143
|
+
'sha256 of the raw file bytes — pass as baseHash to a later update to detect external changes.',
|
|
144
|
+
),
|
|
145
|
+
manifest: z
|
|
146
|
+
.unknown()
|
|
147
|
+
.describe(
|
|
148
|
+
'The raw parsed JSON (not the resolved/defaulted form — see project.manifest.validate for that).',
|
|
149
|
+
),
|
|
150
|
+
})
|
|
151
|
+
.describe('Raw manifest read result.');
|
|
152
|
+
|
|
153
|
+
export const projectManifestRead = defineOperation({
|
|
154
|
+
name: 'project.manifest.read',
|
|
155
|
+
summary: 'Read the raw vgai.game.json manifest for the project.',
|
|
156
|
+
description:
|
|
157
|
+
'File-native read. Returns the raw parsed JSON plus a content hash for optimistic-concurrency updates.',
|
|
158
|
+
input: ProjectManifestReadInput,
|
|
159
|
+
result: ProjectManifestReadResult,
|
|
160
|
+
errors: [NO_PROJECT_ROOT_ERROR, FILE_NOT_FOUND_ERROR],
|
|
161
|
+
requires: { project: true },
|
|
162
|
+
host: 'node',
|
|
163
|
+
mutates: false,
|
|
164
|
+
supportsDryRun: false,
|
|
165
|
+
permission: { risk: 'read', summary: 'Reads vgai.game.json; no writes.' },
|
|
166
|
+
async impl(_input, ctx) {
|
|
167
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
168
|
+
const path = join(projectRoot, MANIFEST_FILENAME);
|
|
169
|
+
const { raw, hash } = readFileWithHash(path);
|
|
170
|
+
return { path, contentHash: hash, manifest: JSON.parse(raw) };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// project.manifest.validate
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
const ProjectManifestValidateInput = z
|
|
179
|
+
.object({
|
|
180
|
+
manifest: z
|
|
181
|
+
.unknown()
|
|
182
|
+
.optional()
|
|
183
|
+
.describe('Validate this raw value instead of re-reading vgai.game.json from disk.'),
|
|
184
|
+
})
|
|
185
|
+
.describe('Validate the on-disk manifest, or a given candidate document.');
|
|
186
|
+
|
|
187
|
+
const ProjectManifestValidateResult = z
|
|
188
|
+
.object({
|
|
189
|
+
valid: z.boolean(),
|
|
190
|
+
issues: z.array(z.string()).describe('Human-readable validation failures (empty when valid).'),
|
|
191
|
+
rootIds: z.array(z.string()).optional().describe('Adapter-root ids, when valid.'),
|
|
192
|
+
})
|
|
193
|
+
.describe('Structured manifest validation result — never throws for an invalid document.');
|
|
194
|
+
|
|
195
|
+
export const projectManifestValidate = defineOperation({
|
|
196
|
+
name: 'project.manifest.validate',
|
|
197
|
+
summary: 'Validate the project manifest against the real engine schema + cross-field rules.',
|
|
198
|
+
description:
|
|
199
|
+
'Reuses @vgai/engine/manifest/load’s loadGameManifest (the same validator the editor/CLI/' +
|
|
200
|
+
'runtime use) — never a second hand-rolled manifest check. Never throws for a malformed ' +
|
|
201
|
+
'document; failures come back as structured issues.',
|
|
202
|
+
input: ProjectManifestValidateInput,
|
|
203
|
+
result: ProjectManifestValidateResult,
|
|
204
|
+
errors: [NO_PROJECT_ROOT_ERROR, FILE_NOT_FOUND_ERROR],
|
|
205
|
+
requires: { project: true },
|
|
206
|
+
host: 'node',
|
|
207
|
+
mutates: false,
|
|
208
|
+
supportsDryRun: false,
|
|
209
|
+
permission: {
|
|
210
|
+
risk: 'read',
|
|
211
|
+
summary: 'Reads vgai.game.json (or validates a given document); no writes.',
|
|
212
|
+
},
|
|
213
|
+
async impl(input, ctx) {
|
|
214
|
+
let raw: unknown;
|
|
215
|
+
if (input.manifest !== undefined) {
|
|
216
|
+
raw = input.manifest;
|
|
217
|
+
} else {
|
|
218
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
219
|
+
raw = JSON.parse(readFileWithHash(join(projectRoot, MANIFEST_FILENAME)).raw);
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const resolved = loadGameManifest(raw);
|
|
223
|
+
return { valid: true, issues: [], rootIds: resolved.roots.map((root) => root.id) };
|
|
224
|
+
} catch (err) {
|
|
225
|
+
const issues =
|
|
226
|
+
err instanceof Error &&
|
|
227
|
+
'issues' in err &&
|
|
228
|
+
Array.isArray((err as { issues: unknown }).issues)
|
|
229
|
+
? (err as { issues: { path: PropertyKey[]; message: string }[] }).issues.map(
|
|
230
|
+
(i) => `${i.path.join('.')}: ${i.message}`,
|
|
231
|
+
)
|
|
232
|
+
: [err instanceof Error ? err.message : String(err)];
|
|
233
|
+
return { valid: false, issues };
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// project.manifest.update — a real mutation: dry-run + external-change
|
|
240
|
+
// detection + exact files-changed, restricted to a safe, explicit set of
|
|
241
|
+
// updatable top-level fields (name/version/resolution). Roots are a
|
|
242
|
+
// separate, more structural concern (adapter/scene/entry cross-field rules)
|
|
243
|
+
// out of scope for this generic field-patch op.
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const ManifestPatchFields = z
|
|
247
|
+
.object({
|
|
248
|
+
name: z.string().optional().describe('New game display name.'),
|
|
249
|
+
version: z
|
|
250
|
+
.string()
|
|
251
|
+
.optional()
|
|
252
|
+
.describe("New game version (the manifest's own, not the engine pin)."),
|
|
253
|
+
resolution: z
|
|
254
|
+
.object({ width: z.number(), height: z.number() })
|
|
255
|
+
.optional()
|
|
256
|
+
.describe('New canvas resolution.'),
|
|
257
|
+
})
|
|
258
|
+
.describe('Top-level manifest fields this op is allowed to change.');
|
|
259
|
+
|
|
260
|
+
const ProjectManifestUpdateInput = z
|
|
261
|
+
.object({
|
|
262
|
+
patch: ManifestPatchFields,
|
|
263
|
+
dryRun: DryRunField,
|
|
264
|
+
baseHash: BaseHashField,
|
|
265
|
+
})
|
|
266
|
+
.describe('Patch a subset of top-level vgai.game.json fields.');
|
|
267
|
+
|
|
268
|
+
const ProjectManifestUpdateResult = mutationResultSchema(
|
|
269
|
+
z
|
|
270
|
+
.object({ manifest: z.unknown() })
|
|
271
|
+
.describe('The manifest document after the patch (or that WOULD result).'),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
export const projectManifestUpdate = defineOperation({
|
|
275
|
+
name: 'project.manifest.update',
|
|
276
|
+
summary: 'Patch name/version/resolution on vgai.game.json, dry-run capable, conflict-checked.',
|
|
277
|
+
description:
|
|
278
|
+
'Merges `patch` onto the raw on-disk manifest, re-validates the FULL merged document through ' +
|
|
279
|
+
'GameManifestSchema (the real schema, cross-field rules included) before ever writing, and ' +
|
|
280
|
+
'refuses the write if the file changed since `baseHash` was read (CONFLICT).',
|
|
281
|
+
input: ProjectManifestUpdateInput,
|
|
282
|
+
result: ProjectManifestUpdateResult,
|
|
283
|
+
errors: [
|
|
284
|
+
NO_PROJECT_ROOT_ERROR,
|
|
285
|
+
FILE_NOT_FOUND_ERROR,
|
|
286
|
+
CONFLICT_ERROR,
|
|
287
|
+
{
|
|
288
|
+
code: 'INVALID_MANIFEST',
|
|
289
|
+
summary: 'The patched manifest fails GameManifestSchema / cross-field validation.',
|
|
290
|
+
data: z.object({ issues: z.array(z.string()) }),
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
requires: { project: true },
|
|
294
|
+
host: 'node',
|
|
295
|
+
mutates: true,
|
|
296
|
+
supportsDryRun: true,
|
|
297
|
+
permission: { risk: 'write', summary: 'Writes vgai.game.json.' },
|
|
298
|
+
async impl(input, ctx) {
|
|
299
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
300
|
+
const path = join(projectRoot, MANIFEST_FILENAME);
|
|
301
|
+
const { raw, hash } = readFileWithHash(path);
|
|
302
|
+
checkNotStale(path, hash, input.baseHash);
|
|
303
|
+
|
|
304
|
+
const before = JSON.parse(raw) as Record<string, unknown>;
|
|
305
|
+
const merged: Record<string, unknown> = { ...before };
|
|
306
|
+
if (input.patch.name !== undefined) merged['name'] = input.patch.name;
|
|
307
|
+
if (input.patch.version !== undefined) merged['version'] = input.patch.version;
|
|
308
|
+
if (input.patch.resolution !== undefined) merged['resolution'] = input.patch.resolution;
|
|
309
|
+
|
|
310
|
+
const parsed = GameManifestSchema.safeParse(merged);
|
|
311
|
+
if (!parsed.success) {
|
|
312
|
+
throw new OperationError(
|
|
313
|
+
'INVALID_MANIFEST',
|
|
314
|
+
'The patched manifest failed schema validation.',
|
|
315
|
+
{
|
|
316
|
+
issues: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
// Cross-field rules (roots uniqueness already inside the schema's
|
|
321
|
+
// superRefine; engine pin / adapter rules live in loadGameManifest).
|
|
322
|
+
try {
|
|
323
|
+
loadGameManifest(merged);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
throw new OperationError(
|
|
326
|
+
'INVALID_MANIFEST',
|
|
327
|
+
'The patched manifest failed cross-field validation.',
|
|
328
|
+
{
|
|
329
|
+
issues: [err instanceof Error ? err.message : String(err)],
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const dryRun = input.dryRun ?? false;
|
|
335
|
+
if (!dryRun) {
|
|
336
|
+
writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}\n`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
dryRun,
|
|
341
|
+
written: !dryRun,
|
|
342
|
+
filesChanged: [path],
|
|
343
|
+
before: { manifest: before },
|
|
344
|
+
after: { manifest: merged },
|
|
345
|
+
};
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/** Register every B2 `project.discover` / `project.manifest.*` operation onto `registry`. */
|
|
350
|
+
export function registerManifestOperations(registry: OperationRegistry): void {
|
|
351
|
+
registry.register(projectDiscover);
|
|
352
|
+
registry.register(projectManifestRead);
|
|
353
|
+
registry.register(projectManifestValidate);
|
|
354
|
+
registry.register(projectManifestUpdate);
|
|
355
|
+
}
|