@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,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `project.asset.*` (B2, §8 B2 "asset list/import/inspect" — "list + inspect
|
|
3
|
+
* real, import may be minimal").
|
|
4
|
+
*
|
|
5
|
+
* Asset root convention: `<projectRoot>/public` — the same convention every
|
|
6
|
+
* scaffolded project and `apply-diff.ts`'s `findAssetRoot` use (scene/asset
|
|
7
|
+
* paths are web-root-relative; Vite serves a project's `public/` directory at
|
|
8
|
+
* that root — see `packages/editor/template/public/`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { dirname, extname, join } from 'node:path';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { OperationError } from '../errors.js';
|
|
15
|
+
import { defineOperation, type OperationRegistry } from '../registry.js';
|
|
16
|
+
import {
|
|
17
|
+
DryRunField,
|
|
18
|
+
mutationResultSchema,
|
|
19
|
+
NO_PROJECT_ROOT_ERROR,
|
|
20
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
21
|
+
readFileWithHash,
|
|
22
|
+
requireProjectRoot,
|
|
23
|
+
resolveProjectPath,
|
|
24
|
+
} from './shared.js';
|
|
25
|
+
|
|
26
|
+
const PUBLIC_DIR = 'public';
|
|
27
|
+
|
|
28
|
+
/** Small text/data files get a content hash too; large/binary files are identified by size alone (hashing a multi-MB texture on every list() would be wasteful). */
|
|
29
|
+
const HASHABLE_MAX_BYTES = 256 * 1024;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// project.asset.list
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const ProjectAssetListInput = z.object({
|
|
36
|
+
subdir: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe(
|
|
40
|
+
'List only under this public/-relative subdirectory (default: the whole public/ dir).',
|
|
41
|
+
),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const AssetEntrySchema = z.object({
|
|
45
|
+
path: z
|
|
46
|
+
.string()
|
|
47
|
+
.describe('public/-relative path (the same convention scene asset references use).'),
|
|
48
|
+
size: z.number().describe('File size in bytes.'),
|
|
49
|
+
ext: z.string().describe('Lowercased file extension including the leading dot, or "" if none.'),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const projectAssetList = defineOperation({
|
|
53
|
+
name: 'project.asset.list',
|
|
54
|
+
summary: 'List every file under the project’s public/ asset root (or a subdirectory of it).',
|
|
55
|
+
description:
|
|
56
|
+
'Read-only recursive listing, public/-relative paths (the same convention scene mesh/material/audio references use).',
|
|
57
|
+
input: ProjectAssetListInput,
|
|
58
|
+
result: z.object({ assets: z.array(AssetEntrySchema) }),
|
|
59
|
+
errors: [NO_PROJECT_ROOT_ERROR, PATH_OUTSIDE_PROJECT_ERROR],
|
|
60
|
+
requires: { project: true },
|
|
61
|
+
host: 'node',
|
|
62
|
+
mutates: false,
|
|
63
|
+
supportsDryRun: false,
|
|
64
|
+
permission: { risk: 'read', summary: 'Reads directory listings under public/; no writes.' },
|
|
65
|
+
async impl(input, ctx) {
|
|
66
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
67
|
+
const startRel = input.subdir ? join(PUBLIC_DIR, input.subdir) : PUBLIC_DIR;
|
|
68
|
+
const startAbs = resolveProjectPath(projectRoot, startRel);
|
|
69
|
+
const assets: { path: string; size: number; ext: string }[] = [];
|
|
70
|
+
|
|
71
|
+
function walk(dir: string): void {
|
|
72
|
+
if (!existsSync(dir)) return;
|
|
73
|
+
for (const name of readdirSync(dir)) {
|
|
74
|
+
const abs = join(dir, name);
|
|
75
|
+
const stat = statSync(abs);
|
|
76
|
+
if (stat.isDirectory()) {
|
|
77
|
+
walk(abs);
|
|
78
|
+
} else {
|
|
79
|
+
const relFromPublic = abs
|
|
80
|
+
.slice(join(projectRoot, PUBLIC_DIR).length + 1)
|
|
81
|
+
.split('\\')
|
|
82
|
+
.join('/');
|
|
83
|
+
assets.push({ path: relFromPublic, size: stat.size, ext: extname(name).toLowerCase() });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
walk(startAbs);
|
|
88
|
+
assets.sort((a, b) => a.path.localeCompare(b.path));
|
|
89
|
+
return { assets };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// project.asset.inspect
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
const ProjectAssetInspectInput = z.object({
|
|
98
|
+
assetPath: z.string().describe('public/-relative path to inspect.'),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const projectAssetInspect = defineOperation({
|
|
102
|
+
name: 'project.asset.inspect',
|
|
103
|
+
summary:
|
|
104
|
+
'Inspect one asset under public/ — existence, size, mtime, and a content hash for small files.',
|
|
105
|
+
description:
|
|
106
|
+
'Read-only. Small files (<= 256KiB) also get a sha256 content hash; larger files are identified by size/mtime alone.',
|
|
107
|
+
input: ProjectAssetInspectInput,
|
|
108
|
+
result: z.object({
|
|
109
|
+
assetPath: z.string(),
|
|
110
|
+
exists: z.boolean(),
|
|
111
|
+
size: z.number().optional(),
|
|
112
|
+
mtimeMs: z.number().optional(),
|
|
113
|
+
ext: z.string().optional(),
|
|
114
|
+
contentHash: z
|
|
115
|
+
.string()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe(`sha256 of the raw bytes, only for files <= ${HASHABLE_MAX_BYTES} bytes.`),
|
|
118
|
+
}),
|
|
119
|
+
errors: [NO_PROJECT_ROOT_ERROR, PATH_OUTSIDE_PROJECT_ERROR],
|
|
120
|
+
requires: { project: true },
|
|
121
|
+
host: 'node',
|
|
122
|
+
mutates: false,
|
|
123
|
+
supportsDryRun: false,
|
|
124
|
+
permission: {
|
|
125
|
+
risk: 'read',
|
|
126
|
+
summary:
|
|
127
|
+
'Reads one file’s metadata (and, for small files, its bytes) under public/; no writes.',
|
|
128
|
+
},
|
|
129
|
+
async impl(input, ctx) {
|
|
130
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
131
|
+
const absPath = resolveProjectPath(projectRoot, join(PUBLIC_DIR, input.assetPath));
|
|
132
|
+
if (!existsSync(absPath) || statSync(absPath).isDirectory()) {
|
|
133
|
+
return { assetPath: input.assetPath, exists: false };
|
|
134
|
+
}
|
|
135
|
+
const stat = statSync(absPath);
|
|
136
|
+
const ext = extname(absPath).toLowerCase();
|
|
137
|
+
if (stat.size <= HASHABLE_MAX_BYTES) {
|
|
138
|
+
const { hash } = readFileWithHash(absPath);
|
|
139
|
+
return {
|
|
140
|
+
assetPath: input.assetPath,
|
|
141
|
+
exists: true,
|
|
142
|
+
size: stat.size,
|
|
143
|
+
mtimeMs: stat.mtimeMs,
|
|
144
|
+
ext,
|
|
145
|
+
contentHash: hash,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
assetPath: input.assetPath,
|
|
150
|
+
exists: true,
|
|
151
|
+
size: stat.size,
|
|
152
|
+
mtimeMs: stat.mtimeMs,
|
|
153
|
+
ext,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// project.asset.import — minimal: copy bytes from an external source path
|
|
160
|
+
// into public/<destPath>. `sourcePath` is deliberately NOT confined to the
|
|
161
|
+
// project (the whole point of "import" is bringing an external file IN) —
|
|
162
|
+
// only `destPath` goes through resolveProjectPath's escape guard.
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
const ProjectAssetImportInput = z.object({
|
|
166
|
+
sourcePath: z.string().describe('Absolute path to the file to import (outside the project).'),
|
|
167
|
+
destPath: z.string().describe('public/-relative destination path.'),
|
|
168
|
+
overwrite: z
|
|
169
|
+
.boolean()
|
|
170
|
+
.optional()
|
|
171
|
+
.describe('Allow overwriting an existing destination file (default false).'),
|
|
172
|
+
dryRun: DryRunField,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
export const projectAssetImport = defineOperation({
|
|
176
|
+
name: 'project.asset.import',
|
|
177
|
+
summary: 'Copy an external file into the project’s public/ asset root.',
|
|
178
|
+
description:
|
|
179
|
+
'Minimal byte-copy import (no format conversion/re-encoding). Refuses to overwrite an existing destination unless `overwrite:true`.',
|
|
180
|
+
input: ProjectAssetImportInput,
|
|
181
|
+
result: mutationResultSchema(z.object({ destPath: z.string(), size: z.number() })),
|
|
182
|
+
errors: [
|
|
183
|
+
NO_PROJECT_ROOT_ERROR,
|
|
184
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
185
|
+
{
|
|
186
|
+
code: 'SOURCE_NOT_FOUND',
|
|
187
|
+
summary: 'sourcePath does not exist or is not a file.',
|
|
188
|
+
data: z.object({ sourcePath: z.string() }),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
code: 'DEST_EXISTS',
|
|
192
|
+
summary: 'destPath already exists and overwrite was not set.',
|
|
193
|
+
data: z.object({ destPath: z.string() }),
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
requires: { project: true },
|
|
197
|
+
host: 'node',
|
|
198
|
+
mutates: true,
|
|
199
|
+
supportsDryRun: true,
|
|
200
|
+
permission: { risk: 'write', summary: 'Writes one file under public/.' },
|
|
201
|
+
async impl(input, ctx) {
|
|
202
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
203
|
+
if (!existsSync(input.sourcePath) || statSync(input.sourcePath).isDirectory()) {
|
|
204
|
+
throw new OperationError('SOURCE_NOT_FOUND', `Source file not found: ${input.sourcePath}`, {
|
|
205
|
+
sourcePath: input.sourcePath,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const destAbs = resolveProjectPath(projectRoot, join(PUBLIC_DIR, input.destPath));
|
|
209
|
+
const destExists = existsSync(destAbs);
|
|
210
|
+
if (destExists && !input.overwrite) {
|
|
211
|
+
throw new OperationError(
|
|
212
|
+
'DEST_EXISTS',
|
|
213
|
+
`${input.destPath} already exists — pass overwrite:true to replace it.`,
|
|
214
|
+
{
|
|
215
|
+
destPath: input.destPath,
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const size = statSync(input.sourcePath).size;
|
|
221
|
+
const dryRun = input.dryRun ?? false;
|
|
222
|
+
if (!dryRun) {
|
|
223
|
+
const destDir = dirname(destAbs);
|
|
224
|
+
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
|
225
|
+
copyFileSync(input.sourcePath, destAbs);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
dryRun,
|
|
230
|
+
written: !dryRun,
|
|
231
|
+
filesChanged: [destAbs],
|
|
232
|
+
before: { destPath: input.destPath, existed: destExists },
|
|
233
|
+
after: { destPath: input.destPath, size },
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/** Register every B2 `project.asset.*` operation onto `registry`. */
|
|
239
|
+
export function registerAssetOperations(registry: OperationRegistry): void {
|
|
240
|
+
registry.register(projectAssetList);
|
|
241
|
+
registry.register(projectAssetInspect);
|
|
242
|
+
registry.register(projectAssetImport);
|
|
243
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `project.scene.component.*` (B2, §8 B2 "component list/add/update/remove").
|
|
3
|
+
*
|
|
4
|
+
* A component's identity within an entity is its NAME (the key in
|
|
5
|
+
* `entity.components`, a `Record<string, Record<string, ComponentValue>>` —
|
|
6
|
+
* see `packages/engine/src/scene/schema/entity.ts`'s `ComponentsSchema`) — so
|
|
7
|
+
* "id-aware" here means addressed by that name, never by e.g. iteration
|
|
8
|
+
* order. Every mutation reuses the exact same path as
|
|
9
|
+
* `project.scene.entity.update`: read the entity (by stable id), compute a
|
|
10
|
+
* new `components` record, hand the FULL merged entity to applyDiff's
|
|
11
|
+
* `update` op. No separate component-patching logic exists.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { SceneEntity } from '@vgai/engine/scene/scene-types';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { OperationError } from '../errors.js';
|
|
17
|
+
import { defineOperation, type OperationRegistry } from '../registry.js';
|
|
18
|
+
import { finalizeSceneMutation } from './entity-operations.js';
|
|
19
|
+
import {
|
|
20
|
+
APPLY_DIFF_FAILED_ERROR,
|
|
21
|
+
applyDiffOrThrow,
|
|
22
|
+
readAndParseScene,
|
|
23
|
+
SCENE_INVALID_ERROR,
|
|
24
|
+
} from './scene-operations.js';
|
|
25
|
+
import {
|
|
26
|
+
BaseHashField,
|
|
27
|
+
CONFLICT_ERROR,
|
|
28
|
+
checkNotStale,
|
|
29
|
+
DryRunField,
|
|
30
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
31
|
+
FILE_NOT_FOUND_ERROR,
|
|
32
|
+
mutationResultSchema,
|
|
33
|
+
NO_PROJECT_ROOT_ERROR,
|
|
34
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
35
|
+
requireEntityById,
|
|
36
|
+
requireProjectRoot,
|
|
37
|
+
} from './shared.js';
|
|
38
|
+
|
|
39
|
+
const ComponentDataValueSchema = z.union([
|
|
40
|
+
z.number(),
|
|
41
|
+
z.string(),
|
|
42
|
+
z.boolean(),
|
|
43
|
+
z.array(z.number()),
|
|
44
|
+
z.array(z.string()),
|
|
45
|
+
z.array(z.boolean()),
|
|
46
|
+
]);
|
|
47
|
+
const ComponentDataSchema = z.record(z.string(), ComponentDataValueSchema);
|
|
48
|
+
|
|
49
|
+
export const COMPONENT_NOT_FOUND_ERROR = {
|
|
50
|
+
code: 'COMPONENT_NOT_FOUND',
|
|
51
|
+
summary: 'No component with the given name is attached to this entity.',
|
|
52
|
+
data: z.object({ entityId: z.string(), componentName: z.string() }),
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
export const COMPONENT_ALREADY_EXISTS_ERROR = {
|
|
56
|
+
code: 'COMPONENT_ALREADY_EXISTS',
|
|
57
|
+
summary:
|
|
58
|
+
'A component with this name is already attached to the entity — use component.update instead.',
|
|
59
|
+
data: z.object({ entityId: z.string(), componentName: z.string() }),
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
/** Build the full entity payload applyDiff's `update` op needs, with `components` replaced by `newComponents`. */
|
|
63
|
+
function entityWithComponents(
|
|
64
|
+
entity: SceneEntity,
|
|
65
|
+
newComponents: Record<string, Record<string, unknown>>,
|
|
66
|
+
): SceneEntity {
|
|
67
|
+
const { id: _id, children: _children, ...fields } = entity;
|
|
68
|
+
return { ...fields, components: newComponents } as SceneEntity;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// project.scene.component.list
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const ProjectSceneComponentListInput = z.object({
|
|
76
|
+
scenePath: z.string().describe('Project-relative path to the .vscn.json scene file.'),
|
|
77
|
+
entityId: z.string().describe('Entity to list components for.'),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const projectSceneComponentList = defineOperation({
|
|
81
|
+
name: 'project.scene.component.list',
|
|
82
|
+
summary: 'List every component (by name) attached to an entity.',
|
|
83
|
+
description: 'Read-only. Resolves the entity by stable id.',
|
|
84
|
+
input: ProjectSceneComponentListInput,
|
|
85
|
+
result: z.object({
|
|
86
|
+
entityId: z.string(),
|
|
87
|
+
components: z
|
|
88
|
+
.record(z.string(), ComponentDataSchema)
|
|
89
|
+
.describe('Component name -> its data record.'),
|
|
90
|
+
}),
|
|
91
|
+
errors: [
|
|
92
|
+
NO_PROJECT_ROOT_ERROR,
|
|
93
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
94
|
+
FILE_NOT_FOUND_ERROR,
|
|
95
|
+
SCENE_INVALID_ERROR,
|
|
96
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
97
|
+
],
|
|
98
|
+
requires: { project: true },
|
|
99
|
+
host: 'node',
|
|
100
|
+
mutates: false,
|
|
101
|
+
supportsDryRun: false,
|
|
102
|
+
permission: { risk: 'read', summary: 'Reads a scene file; no writes.' },
|
|
103
|
+
async impl(input, ctx) {
|
|
104
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
105
|
+
const { scene } = readAndParseScene(projectRoot, input.scenePath);
|
|
106
|
+
const entity = requireEntityById(scene.entities, input.entityId);
|
|
107
|
+
return { entityId: input.entityId, components: entity.components ?? {} };
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// project.scene.component.add
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
const ProjectSceneComponentAddInput = z.object({
|
|
116
|
+
scenePath: z.string().describe('Project-relative path to the .vscn.json scene file.'),
|
|
117
|
+
entityId: z.string().describe('Entity to attach the component to.'),
|
|
118
|
+
componentName: z.string().describe('Component name (record key).'),
|
|
119
|
+
data: ComponentDataSchema.describe('Initial component data.'),
|
|
120
|
+
dryRun: DryRunField,
|
|
121
|
+
baseHash: BaseHashField,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const ComponentAfterSchema = z.object({
|
|
125
|
+
entityId: z.string(),
|
|
126
|
+
componentName: z.string(),
|
|
127
|
+
data: z.unknown(),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const projectSceneComponentAdd = defineOperation({
|
|
131
|
+
name: 'project.scene.component.add',
|
|
132
|
+
summary: 'Attach a new named component to an entity.',
|
|
133
|
+
description:
|
|
134
|
+
'Delegates to applyDiff’s `update` op with a merged components record — same reused write path as project.scene.entity.update.',
|
|
135
|
+
input: ProjectSceneComponentAddInput,
|
|
136
|
+
result: mutationResultSchema(ComponentAfterSchema),
|
|
137
|
+
errors: [
|
|
138
|
+
NO_PROJECT_ROOT_ERROR,
|
|
139
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
140
|
+
FILE_NOT_FOUND_ERROR,
|
|
141
|
+
SCENE_INVALID_ERROR,
|
|
142
|
+
APPLY_DIFF_FAILED_ERROR,
|
|
143
|
+
CONFLICT_ERROR,
|
|
144
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
145
|
+
COMPONENT_ALREADY_EXISTS_ERROR,
|
|
146
|
+
],
|
|
147
|
+
requires: { project: true },
|
|
148
|
+
host: 'node',
|
|
149
|
+
mutates: true,
|
|
150
|
+
supportsDryRun: true,
|
|
151
|
+
permission: { risk: 'write', summary: 'Writes the target scene file.' },
|
|
152
|
+
async impl(input, ctx) {
|
|
153
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
154
|
+
const { absPath, hash, scene } = readAndParseScene(projectRoot, input.scenePath);
|
|
155
|
+
checkNotStale(absPath, hash, input.baseHash);
|
|
156
|
+
|
|
157
|
+
const entity = requireEntityById(scene.entities, input.entityId);
|
|
158
|
+
const existing = entity.components ?? {};
|
|
159
|
+
if (input.componentName in existing) {
|
|
160
|
+
throw new OperationError(
|
|
161
|
+
'COMPONENT_ALREADY_EXISTS',
|
|
162
|
+
`Entity "${input.entityId}" already has a component named "${input.componentName}" — use project.scene.component.update instead.`,
|
|
163
|
+
{ entityId: input.entityId, componentName: input.componentName },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const newComponents = { ...existing, [input.componentName]: input.data };
|
|
167
|
+
|
|
168
|
+
const result = applyDiffOrThrow(scene, {
|
|
169
|
+
ops: [
|
|
170
|
+
{ type: 'update', id: input.entityId, entity: entityWithComponents(entity, newComponents) },
|
|
171
|
+
],
|
|
172
|
+
environmentChanged: false,
|
|
173
|
+
metaChanged: false,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return finalizeSceneMutation(absPath, input.scenePath, result, input.dryRun ?? false, null, {
|
|
177
|
+
entityId: input.entityId,
|
|
178
|
+
componentName: input.componentName,
|
|
179
|
+
data: input.data,
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// project.scene.component.update — merge new fields onto the existing component's data.
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
const ProjectSceneComponentUpdateInput = z.object({
|
|
189
|
+
scenePath: z.string().describe('Project-relative path to the .vscn.json scene file.'),
|
|
190
|
+
entityId: z.string().describe('Entity the component is attached to.'),
|
|
191
|
+
componentName: z.string().describe('Component name (record key) to update.'),
|
|
192
|
+
data: ComponentDataSchema.describe(
|
|
193
|
+
'Fields to overlay onto the component’s current data (a merge).',
|
|
194
|
+
),
|
|
195
|
+
dryRun: DryRunField,
|
|
196
|
+
baseHash: BaseHashField,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
export const projectSceneComponentUpdate = defineOperation({
|
|
200
|
+
name: 'project.scene.component.update',
|
|
201
|
+
summary: 'Merge field changes onto an existing named component.',
|
|
202
|
+
description: 'Delegates to applyDiff’s `update` op with a merged components record.',
|
|
203
|
+
input: ProjectSceneComponentUpdateInput,
|
|
204
|
+
result: mutationResultSchema(ComponentAfterSchema),
|
|
205
|
+
errors: [
|
|
206
|
+
NO_PROJECT_ROOT_ERROR,
|
|
207
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
208
|
+
FILE_NOT_FOUND_ERROR,
|
|
209
|
+
SCENE_INVALID_ERROR,
|
|
210
|
+
APPLY_DIFF_FAILED_ERROR,
|
|
211
|
+
CONFLICT_ERROR,
|
|
212
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
213
|
+
COMPONENT_NOT_FOUND_ERROR,
|
|
214
|
+
],
|
|
215
|
+
requires: { project: true },
|
|
216
|
+
host: 'node',
|
|
217
|
+
mutates: true,
|
|
218
|
+
supportsDryRun: true,
|
|
219
|
+
permission: { risk: 'write', summary: 'Writes the target scene file.' },
|
|
220
|
+
async impl(input, ctx) {
|
|
221
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
222
|
+
const { absPath, hash, scene } = readAndParseScene(projectRoot, input.scenePath);
|
|
223
|
+
checkNotStale(absPath, hash, input.baseHash);
|
|
224
|
+
|
|
225
|
+
const entity = requireEntityById(scene.entities, input.entityId);
|
|
226
|
+
const existing = entity.components ?? {};
|
|
227
|
+
const currentData = existing[input.componentName];
|
|
228
|
+
if (currentData === undefined) {
|
|
229
|
+
throw new OperationError(
|
|
230
|
+
'COMPONENT_NOT_FOUND',
|
|
231
|
+
`Entity "${input.entityId}" has no component named "${input.componentName}".`,
|
|
232
|
+
{ entityId: input.entityId, componentName: input.componentName },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const mergedData = { ...currentData, ...input.data };
|
|
236
|
+
const newComponents = { ...existing, [input.componentName]: mergedData };
|
|
237
|
+
|
|
238
|
+
const result = applyDiffOrThrow(scene, {
|
|
239
|
+
ops: [
|
|
240
|
+
{ type: 'update', id: input.entityId, entity: entityWithComponents(entity, newComponents) },
|
|
241
|
+
],
|
|
242
|
+
environmentChanged: false,
|
|
243
|
+
metaChanged: false,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return finalizeSceneMutation(
|
|
247
|
+
absPath,
|
|
248
|
+
input.scenePath,
|
|
249
|
+
result,
|
|
250
|
+
input.dryRun ?? false,
|
|
251
|
+
{ entityId: input.entityId, componentName: input.componentName, data: currentData },
|
|
252
|
+
{ entityId: input.entityId, componentName: input.componentName, data: mergedData },
|
|
253
|
+
);
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// project.scene.component.remove
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
const ProjectSceneComponentRemoveInput = z.object({
|
|
262
|
+
scenePath: z.string().describe('Project-relative path to the .vscn.json scene file.'),
|
|
263
|
+
entityId: z.string().describe('Entity the component is attached to.'),
|
|
264
|
+
componentName: z.string().describe('Component name (record key) to remove.'),
|
|
265
|
+
dryRun: DryRunField,
|
|
266
|
+
baseHash: BaseHashField,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
export const projectSceneComponentRemove = defineOperation({
|
|
270
|
+
name: 'project.scene.component.remove',
|
|
271
|
+
summary: 'Detach a named component from an entity.',
|
|
272
|
+
description:
|
|
273
|
+
'Delegates to applyDiff’s `update` op with the component key deleted from the components record.',
|
|
274
|
+
input: ProjectSceneComponentRemoveInput,
|
|
275
|
+
result: mutationResultSchema(ComponentAfterSchema),
|
|
276
|
+
errors: [
|
|
277
|
+
NO_PROJECT_ROOT_ERROR,
|
|
278
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
279
|
+
FILE_NOT_FOUND_ERROR,
|
|
280
|
+
SCENE_INVALID_ERROR,
|
|
281
|
+
APPLY_DIFF_FAILED_ERROR,
|
|
282
|
+
CONFLICT_ERROR,
|
|
283
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
284
|
+
COMPONENT_NOT_FOUND_ERROR,
|
|
285
|
+
],
|
|
286
|
+
requires: { project: true },
|
|
287
|
+
host: 'node',
|
|
288
|
+
mutates: true,
|
|
289
|
+
supportsDryRun: true,
|
|
290
|
+
permission: {
|
|
291
|
+
risk: 'destructive',
|
|
292
|
+
summary: 'Removes a component from an entity in the target scene file.',
|
|
293
|
+
},
|
|
294
|
+
async impl(input, ctx) {
|
|
295
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
296
|
+
const { absPath, hash, scene } = readAndParseScene(projectRoot, input.scenePath);
|
|
297
|
+
checkNotStale(absPath, hash, input.baseHash);
|
|
298
|
+
|
|
299
|
+
const entity = requireEntityById(scene.entities, input.entityId);
|
|
300
|
+
const existing = entity.components ?? {};
|
|
301
|
+
const currentData = existing[input.componentName];
|
|
302
|
+
if (currentData === undefined) {
|
|
303
|
+
throw new OperationError(
|
|
304
|
+
'COMPONENT_NOT_FOUND',
|
|
305
|
+
`Entity "${input.entityId}" has no component named "${input.componentName}".`,
|
|
306
|
+
{ entityId: input.entityId, componentName: input.componentName },
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const newComponents = { ...existing };
|
|
310
|
+
delete newComponents[input.componentName];
|
|
311
|
+
|
|
312
|
+
const result = applyDiffOrThrow(scene, {
|
|
313
|
+
ops: [
|
|
314
|
+
{ type: 'update', id: input.entityId, entity: entityWithComponents(entity, newComponents) },
|
|
315
|
+
],
|
|
316
|
+
environmentChanged: false,
|
|
317
|
+
metaChanged: false,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return finalizeSceneMutation(
|
|
321
|
+
absPath,
|
|
322
|
+
input.scenePath,
|
|
323
|
+
result,
|
|
324
|
+
input.dryRun ?? false,
|
|
325
|
+
{ entityId: input.entityId, componentName: input.componentName, data: currentData },
|
|
326
|
+
{ entityId: input.entityId, componentName: input.componentName, data: null },
|
|
327
|
+
);
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/** Register every B2 `project.scene.component.*` operation onto `registry`. */
|
|
332
|
+
export function registerComponentOperations(registry: OperationRegistry): void {
|
|
333
|
+
registry.register(projectSceneComponentList);
|
|
334
|
+
registry.register(projectSceneComponentAdd);
|
|
335
|
+
registry.register(projectSceneComponentUpdate);
|
|
336
|
+
registry.register(projectSceneComponentRemove);
|
|
337
|
+
}
|