@jgengine/editor 0.10.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/dist/AssetBrowser.d.ts +20 -0
  3. package/dist/AssetBrowser.js +49 -0
  4. package/dist/CatalogsPanel.d.ts +10 -0
  5. package/dist/CatalogsPanel.js +41 -0
  6. package/dist/CollectionsPanel.d.ts +10 -0
  7. package/dist/CollectionsPanel.js +35 -0
  8. package/dist/DebugDraw.d.ts +17 -0
  9. package/dist/DebugDraw.js +233 -0
  10. package/dist/EditorApp.d.ts +26 -0
  11. package/dist/EditorApp.js +333 -0
  12. package/dist/EditorCameraDriver.d.ts +5 -0
  13. package/dist/EditorCameraDriver.js +18 -0
  14. package/dist/EditorChrome.d.ts +22 -0
  15. package/dist/EditorChrome.js +559 -0
  16. package/dist/EditorContextMenu.d.ts +11 -0
  17. package/dist/EditorContextMenu.js +12 -0
  18. package/dist/InspectorPanel.d.ts +14 -0
  19. package/dist/InspectorPanel.js +110 -0
  20. package/dist/MaterialDropZone.d.ts +10 -0
  21. package/dist/MaterialDropZone.js +77 -0
  22. package/dist/OutlinerPanel.d.ts +14 -0
  23. package/dist/OutlinerPanel.js +84 -0
  24. package/dist/PerfProbe.d.ts +5 -0
  25. package/dist/PerfProbe.js +34 -0
  26. package/dist/PrefabsPanel.d.ts +13 -0
  27. package/dist/PrefabsPanel.js +42 -0
  28. package/dist/RuntimePlayBridge.d.ts +18 -0
  29. package/dist/RuntimePlayBridge.js +140 -0
  30. package/dist/ScatterPreview.d.ts +11 -0
  31. package/dist/ScatterPreview.js +30 -0
  32. package/dist/SchemaInspector.d.ts +19 -0
  33. package/dist/SchemaInspector.js +78 -0
  34. package/dist/SelectionGizmo.d.ts +25 -0
  35. package/dist/SelectionGizmo.js +455 -0
  36. package/dist/StandaloneEditor.d.ts +44 -0
  37. package/dist/StandaloneEditor.js +92 -0
  38. package/dist/TerrainPanel.d.ts +7 -0
  39. package/dist/TerrainPanel.js +59 -0
  40. package/dist/TerrainSculpt.d.ts +14 -0
  41. package/dist/TerrainSculpt.js +415 -0
  42. package/dist/TriggerInspector.d.ts +13 -0
  43. package/dist/TriggerInspector.js +60 -0
  44. package/dist/agent/AgentPanel.d.ts +13 -0
  45. package/dist/agent/AgentPanel.js +156 -0
  46. package/dist/agent/context.d.ts +27 -0
  47. package/dist/agent/context.js +39 -0
  48. package/dist/agent/endpoint.d.ts +68 -0
  49. package/dist/agent/endpoint.js +186 -0
  50. package/dist/agent/toolBridge.d.ts +35 -0
  51. package/dist/agent/toolBridge.js +93 -0
  52. package/dist/agent/turn.d.ts +71 -0
  53. package/dist/agent/turn.js +113 -0
  54. package/dist/chromeFields.d.ts +15 -0
  55. package/dist/chromeFields.js +13 -0
  56. package/dist/chromeStyles.d.ts +3 -0
  57. package/dist/chromeStyles.js +3 -0
  58. package/dist/index.d.ts +20 -0
  59. package/dist/index.js +19 -0
  60. package/dist/mcp/bridgeServer.d.ts +13 -0
  61. package/dist/mcp/bridgeServer.js +1 -0
  62. package/dist/mcp/loadGameCatalogs.d.ts +24 -0
  63. package/dist/mcp/loadGameCatalogs.js +87 -0
  64. package/dist/mcp/rpcRequest.d.ts +22 -0
  65. package/dist/mcp/rpcRequest.js +97 -0
  66. package/dist/mcp/tools.d.ts +8 -0
  67. package/dist/mcp/tools.js +764 -0
  68. package/dist/outlinerModel.d.ts +76 -0
  69. package/dist/outlinerModel.js +95 -0
  70. package/dist/perfMarks.d.ts +25 -0
  71. package/dist/perfMarks.js +36 -0
  72. package/dist/session.d.ts +400 -0
  73. package/dist/session.js +1032 -0
  74. package/dist/uiStore.d.ts +108 -0
  75. package/dist/uiStore.js +128 -0
  76. package/dist/useF2Chord.d.ts +2 -0
  77. package/dist/useF2Chord.js +32 -0
  78. package/dist/useStoreSelector.d.ts +31 -0
  79. package/dist/useStoreSelector.js +51 -0
  80. package/dist/viewportContextMenu.d.ts +24 -0
  81. package/dist/viewportContextMenu.js +32 -0
  82. package/package.json +58 -0
  83. package/skills/jgengine/SKILL.md +823 -0
  84. package/skills/jgengine/api.md +416 -0
  85. package/skills/jgengine/capabilities.md +21 -0
  86. package/skills/jgengine/reference-cartridge.md +72 -0
  87. package/skills/jgengine/reference-orphans.md +9 -0
  88. package/skills/jgengine/reference-systems.md +60 -0
  89. package/skills/jgengine-editor/SKILL.md +382 -0
  90. package/skills/jgengine-editor/api.md +416 -0
  91. package/skills/jgengine-editor/capabilities.md +29 -0
  92. package/skills/jgengine-editor/reference.md +64 -0
@@ -0,0 +1,1032 @@
1
+ import { createDocumentLiveSync, createEditorSession, createRuntimePlayControl, editorChildren, editorDocumentBounds, editorParentOf, editorRoots, findEditorCatalog, findEditorCatalogEntry, findEditorCollection, findEditorPrefab, getRuntimeInspectorValue, installDocumentLiveSync, listEditorKinds, normalizeEditorLayers, planRuntimeInspectorSet, runtimeEntityMetaWriteBackCommand, runtimeEntityWriteBackCommand, seedEditorCatalogs, summarizeEditorSession, summarizeRuntimeInspector, } from "@jgengine/core/editor/index";
2
+ import { createTerrainSnapshot, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
3
+ import { resolvePlaceAsset, toEditorMarker } from "@jgengine/core/world/placeAsset";
4
+ import { resolveScatter, resolveScatterRegion, scatterRegionEstimate, scatterRegionFromPath, SCATTER_PATH_KIND, } from "@jgengine/core/world/scatterRegion";
5
+ import { registerBuiltinSceneKinds } from "@jgengine/core/scene/builtinSceneKinds";
6
+ import { getSceneKind, validateParams } from "@jgengine/core/scene/sceneKinds";
7
+ import { TERRAIN_MATERIALS } from "./uiStore.js";
8
+ registerBuiltinSceneKinds();
9
+ /** Which document collection an object lives in, plus its current kind + meta ΓÇö for generic `set_meta`. */
10
+ function findMetaTarget(doc, id) {
11
+ const marker = doc.markers.find((m) => m.id === id);
12
+ if (marker !== undefined)
13
+ return { target: "marker", kind: marker.kind, meta: marker.meta };
14
+ const volume = doc.volumes.find((v) => v.id === id);
15
+ if (volume !== undefined)
16
+ return { target: "volume", kind: volume.kind, meta: volume.meta };
17
+ const path = doc.paths.find((p) => p.id === id);
18
+ if (path !== undefined)
19
+ return { target: "path", kind: path.kind, meta: path.meta };
20
+ const note = doc.annotations.find((n) => n.id === id);
21
+ if (note !== undefined)
22
+ return { target: "note", kind: "note", meta: note.meta };
23
+ return null;
24
+ }
25
+ /** Validates a merged meta bag against a registered kind's schema; returns an error string or null. */
26
+ function validateMetaForKind(kind, meta) {
27
+ const definition = getSceneKind(kind);
28
+ if (definition === undefined || meta === undefined)
29
+ return null;
30
+ const issues = validateParams(definition.schema, meta);
31
+ if (issues.length === 0)
32
+ return null;
33
+ return `invalid ${kind} params: ${issues.map((issue) => `${issue.key} (${issue.message})`).join(", ")}`;
34
+ }
35
+ const EDITOR_RUN_MODES = ["edit", "walk", "play"];
36
+ const GLOBAL_KEY = "__jgengineEditorHost";
37
+ /** Publishes an editor host globally so devtools and MCP agents can reach it; returns a cleanup fn. */
38
+ export function installEditorHost(api) {
39
+ const root = globalThis;
40
+ root[GLOBAL_KEY] = api;
41
+ return () => {
42
+ if (root[GLOBAL_KEY] === api)
43
+ delete root[GLOBAL_KEY];
44
+ };
45
+ }
46
+ /** Retrieves the globally installed editor host, or null if none is mounted. @internal */
47
+ export function getEditorHost() {
48
+ const root = globalThis;
49
+ return root[GLOBAL_KEY] ?? null;
50
+ }
51
+ /** Builds and installs an editor host for a game: session, visibility, assets, and RPC handling. @internal */
52
+ export function createEditorHost(options) {
53
+ const catalogDefinitions = options.catalogs ?? [];
54
+ const catalogById = new Map(catalogDefinitions.map((definition) => [definition.id, definition]));
55
+ const document = seedEditorCatalogs(normalizeEditorLayers(options.layers), catalogDefinitions);
56
+ const session = createEditorSession(document);
57
+ const liveSync = createDocumentLiveSync(document);
58
+ const uninstallLiveSync = installDocumentLiveSync(liveSync);
59
+ let lastMirroredDocument = session.getState().document;
60
+ const unsubscribeSessionMirror = session.subscribe((state) => {
61
+ if (state.document === lastMirroredDocument)
62
+ return;
63
+ lastMirroredDocument = state.document;
64
+ liveSync.replaceDocument(state.document);
65
+ });
66
+ let visibility = {};
67
+ let focusTarget = null;
68
+ let assets = [...(options.assets ?? [])];
69
+ let perf = null;
70
+ let mode = "edit";
71
+ let playControl = createRuntimePlayControl(false);
72
+ const visibilityListeners = new Set();
73
+ const focusListeners = new Set();
74
+ const modeListeners = new Set();
75
+ const playControlListeners = new Set();
76
+ /** Dispatches a command and reports whether it actually mutated the session ΓÇö a rejected
77
+ * mutation (locked target, cyclic parent, nothing to undo/redo, …) returns the session's prior
78
+ * state object unchanged, so identity tells the RPC layer apart from a real edit landing. */
79
+ const dispatchGuarded = (command) => {
80
+ const before = session.getState();
81
+ const state = session.dispatch(command);
82
+ return { applied: state !== before, state };
83
+ };
84
+ const kinds = listEditorKinds(document);
85
+ // Heavy / dense kinds off by default ΓÇö turn on from the Layers panel when needed.
86
+ const defaultOff = new Set([
87
+ "aggro",
88
+ "leash",
89
+ "respawn_skip",
90
+ "discover",
91
+ "flatten",
92
+ "cluster",
93
+ "road",
94
+ ]);
95
+ for (const kind of [...kinds.markers, ...kinds.volumes, ...kinds.paths]) {
96
+ visibility[kind] = !defaultOff.has(kind);
97
+ }
98
+ const api = {
99
+ gameId: options.gameId,
100
+ getSession: () => session,
101
+ getLiveSync: () => liveSync,
102
+ getVisibility: () => visibility,
103
+ setVisibility(next) {
104
+ visibility = { ...next };
105
+ for (const listener of visibilityListeners)
106
+ listener();
107
+ },
108
+ subscribeVisibility(listener) {
109
+ visibilityListeners.add(listener);
110
+ return () => {
111
+ visibilityListeners.delete(listener);
112
+ };
113
+ },
114
+ getFocusTarget: () => focusTarget,
115
+ setFocusTarget(target) {
116
+ focusTarget = target;
117
+ options.onFocus?.(target);
118
+ for (const listener of focusListeners)
119
+ listener(target);
120
+ },
121
+ subscribeFocus(listener) {
122
+ focusListeners.add(listener);
123
+ return () => {
124
+ focusListeners.delete(listener);
125
+ };
126
+ },
127
+ getAssets: () => assets,
128
+ setAssets(next) {
129
+ assets = [...next];
130
+ },
131
+ getCatalogDefinitions: () => catalogDefinitions,
132
+ getPerf: () => perf,
133
+ setPerf(sample) {
134
+ perf = sample;
135
+ },
136
+ getMode: () => mode,
137
+ setMode(next) {
138
+ if (next === mode)
139
+ return;
140
+ mode = next;
141
+ if (next === "play") {
142
+ playControl = createRuntimePlayControl(false);
143
+ for (const listener of playControlListeners)
144
+ listener(playControl);
145
+ }
146
+ for (const listener of modeListeners)
147
+ listener(mode);
148
+ },
149
+ subscribeMode(listener) {
150
+ modeListeners.add(listener);
151
+ return () => {
152
+ modeListeners.delete(listener);
153
+ };
154
+ },
155
+ getPlayControl: () => playControl,
156
+ setPlayControl(next) {
157
+ playControl = {
158
+ paused: next.paused,
159
+ pendingSteps: Math.max(0, Math.floor(next.pendingSteps)),
160
+ };
161
+ for (const listener of playControlListeners)
162
+ listener(playControl);
163
+ },
164
+ subscribePlayControl(listener) {
165
+ playControlListeners.add(listener);
166
+ return () => {
167
+ playControlListeners.delete(listener);
168
+ };
169
+ },
170
+ handle(request) {
171
+ try {
172
+ switch (request.method) {
173
+ case "editor_status":
174
+ return {
175
+ ok: true,
176
+ result: {
177
+ gameId: options.gameId,
178
+ connected: true,
179
+ ...summarizeEditorSession(session.getState()),
180
+ canUndo: session.canUndo(),
181
+ canRedo: session.canRedo(),
182
+ perf,
183
+ mode,
184
+ },
185
+ };
186
+ case "set_mode": {
187
+ if (!EDITOR_RUN_MODES.includes(request.mode)) {
188
+ return { ok: false, error: `unknown mode: ${String(request.mode)} (edit | walk | play)` };
189
+ }
190
+ api.setMode(request.mode);
191
+ return { ok: true, result: { mode: request.mode } };
192
+ }
193
+ case "perf_report": {
194
+ const devtoolsGlobal = globalThis.__JG_DEVTOOLS;
195
+ if (devtoolsGlobal?.snapshot === undefined) {
196
+ return {
197
+ ok: false,
198
+ error: "engine devtools not mounted ΓÇö open the browser editor (F2+D panel) first",
199
+ };
200
+ }
201
+ return { ok: true, result: { perf, report: devtoolsGlobal.snapshot() } };
202
+ }
203
+ case "list_layers": {
204
+ const state = session.getState();
205
+ return {
206
+ ok: true,
207
+ result: {
208
+ kinds: listEditorKinds(state.document),
209
+ visibility,
210
+ document: state.document,
211
+ },
212
+ };
213
+ }
214
+ case "list_catalogs": {
215
+ const state = session.getState();
216
+ const catalogs = catalogDefinitions.map((definition) => {
217
+ const data = findEditorCatalog(state.document, definition.id);
218
+ return {
219
+ id: definition.id,
220
+ label: definition.label,
221
+ schema: definition.schema,
222
+ entryCount: data?.entries.length ?? definition.entries.length,
223
+ entries: (data?.entries ?? definition.entries).map((entry) => ({
224
+ id: entry.id,
225
+ label: entry.label,
226
+ })),
227
+ };
228
+ });
229
+ return { ok: true, result: { catalogs } };
230
+ }
231
+ case "get_catalog_entry": {
232
+ const definition = catalogById.get(request.catalogId);
233
+ if (definition === undefined)
234
+ return { ok: false, error: `catalog not found: ${request.catalogId}` };
235
+ const entry = findEditorCatalogEntry(session.getState().document, request.catalogId, request.entryId);
236
+ if (entry === undefined)
237
+ return { ok: false, error: `catalog entry not found: ${request.catalogId}/${request.entryId}` };
238
+ return {
239
+ ok: true,
240
+ result: {
241
+ catalogId: request.catalogId,
242
+ label: definition.label,
243
+ schema: definition.schema,
244
+ entry,
245
+ },
246
+ };
247
+ }
248
+ case "set_catalog_entry": {
249
+ const definition = catalogById.get(request.catalogId);
250
+ if (definition === undefined)
251
+ return { ok: false, error: `catalog not found: ${request.catalogId}` };
252
+ const current = findEditorCatalogEntry(session.getState().document, request.catalogId, request.entryId);
253
+ if (current === undefined)
254
+ return { ok: false, error: `catalog entry not found: ${request.catalogId}/${request.entryId}` };
255
+ const merged = { ...current.meta, ...request.patch };
256
+ const invalid = validateParams(definition.schema, merged);
257
+ if (invalid.length > 0) {
258
+ return {
259
+ ok: false,
260
+ error: `invalid ${request.catalogId} params: ${invalid.map((issue) => `${issue.key} (${issue.message})`).join(", ")}`,
261
+ };
262
+ }
263
+ const coalesceKey = Object.keys(request.patch).length === 1
264
+ ? `catalog:${request.catalogId}:${request.entryId}:${Object.keys(request.patch)[0]}`
265
+ : `catalog:${request.catalogId}:${request.entryId}`;
266
+ const before = session.getState();
267
+ const state = session.dispatch({
268
+ type: "setCatalogEntry",
269
+ catalogId: request.catalogId,
270
+ entryId: request.entryId,
271
+ patch: {
272
+ meta: merged,
273
+ ...(request.label === undefined ? {} : { label: request.label }),
274
+ },
275
+ }, { coalesce: coalesceKey });
276
+ if (state === before) {
277
+ return { ok: false, error: `set_catalog_entry rejected: ${request.catalogId}/${request.entryId}` };
278
+ }
279
+ const entry = findEditorCatalogEntry(state.document, request.catalogId, request.entryId);
280
+ return { ok: true, result: { catalogId: request.catalogId, entry } };
281
+ }
282
+ case "list_selection":
283
+ return { ok: true, result: { selection: session.getState().selection } };
284
+ case "get_marker": {
285
+ const marker = session.getState().document.markers.find((m) => m.id === request.id);
286
+ return marker === undefined
287
+ ? { ok: false, error: `marker not found: ${request.id}` }
288
+ : { ok: true, result: marker };
289
+ }
290
+ case "get_volume": {
291
+ const volume = session.getState().document.volumes.find((v) => v.id === request.id);
292
+ return volume === undefined
293
+ ? { ok: false, error: `volume not found: ${request.id}` }
294
+ : { ok: true, result: volume };
295
+ }
296
+ case "set_transform": {
297
+ const marker = session.getState().document.markers.find((m) => m.id === request.id);
298
+ const volume = session.getState().document.volumes.find((v) => v.id === request.id);
299
+ const base = marker?.position ?? volume?.center;
300
+ if (base === undefined)
301
+ return { ok: false, error: `id not found: ${request.id}` };
302
+ const { applied, state } = dispatchGuarded({
303
+ type: "setTransform",
304
+ id: request.id,
305
+ position: {
306
+ x: request.x ?? base.x,
307
+ y: request.y ?? base.y,
308
+ z: request.z ?? base.z,
309
+ },
310
+ ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
311
+ });
312
+ if (!applied)
313
+ return { ok: false, error: `set_transform rejected: ${request.id} is locked` };
314
+ return { ok: true, result: summarizeEditorSession(state) };
315
+ }
316
+ case "set_volume": {
317
+ const volume = session.getState().document.volumes.find((v) => v.id === request.id);
318
+ if (volume === undefined)
319
+ return { ok: false, error: `volume not found: ${request.id}` };
320
+ session.dispatch({
321
+ type: "setVolume",
322
+ id: request.id,
323
+ patch: {
324
+ ...(request.radius === undefined ? {} : { radius: request.radius }),
325
+ ...(request.height === undefined ? {} : { height: request.height }),
326
+ ...(request.x === undefined && request.y === undefined && request.z === undefined
327
+ ? {}
328
+ : {
329
+ center: {
330
+ x: request.x ?? volume.center.x,
331
+ y: request.y ?? volume.center.y,
332
+ z: request.z ?? volume.center.z,
333
+ },
334
+ }),
335
+ },
336
+ });
337
+ return { ok: true, result: session.getState().document.volumes.find((v) => v.id === request.id) };
338
+ }
339
+ case "set_path": {
340
+ const doc = session.getState().document;
341
+ const path = doc.paths.find((p) => p.id === request.id);
342
+ if (path === undefined)
343
+ return { ok: false, error: `path not found: ${request.id}` };
344
+ const merged = request.meta === undefined ? undefined : { ...path.meta, ...request.meta };
345
+ const kind = request.kind ?? path.kind;
346
+ const invalid = validateMetaForKind(kind, merged);
347
+ if (invalid !== null)
348
+ return { ok: false, error: invalid };
349
+ session.dispatch({
350
+ type: "setPath",
351
+ id: request.id,
352
+ patch: {
353
+ ...(request.kind === undefined ? {} : { kind: request.kind }),
354
+ ...(request.width === undefined ? {} : { width: request.width }),
355
+ ...(request.color === undefined ? {} : { color: request.color }),
356
+ ...(request.label === undefined ? {} : { label: request.label }),
357
+ ...(merged === undefined ? {} : { meta: merged }),
358
+ },
359
+ });
360
+ return { ok: true, result: session.getState().document.paths.find((p) => p.id === request.id) };
361
+ }
362
+ case "set_marker": {
363
+ const doc = session.getState().document;
364
+ const marker = doc.markers.find((m) => m.id === request.id);
365
+ if (marker === undefined)
366
+ return { ok: false, error: `marker not found: ${request.id}` };
367
+ const merged = request.meta === undefined ? undefined : { ...marker.meta, ...request.meta };
368
+ const kind = request.kind ?? marker.kind;
369
+ const invalid = validateMetaForKind(kind, merged);
370
+ if (invalid !== null)
371
+ return { ok: false, error: invalid };
372
+ session.dispatch({
373
+ type: "setMarker",
374
+ id: request.id,
375
+ patch: {
376
+ ...(request.kind === undefined ? {} : { kind: request.kind }),
377
+ ...(request.color === undefined ? {} : { color: request.color }),
378
+ ...(request.label === undefined ? {} : { label: request.label }),
379
+ ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
380
+ ...(merged === undefined ? {} : { meta: merged }),
381
+ },
382
+ });
383
+ return { ok: true, result: session.getState().document.markers.find((m) => m.id === request.id) };
384
+ }
385
+ case "set_note": {
386
+ const doc = session.getState().document;
387
+ const note = doc.annotations.find((n) => n.id === request.id);
388
+ if (note === undefined)
389
+ return { ok: false, error: `note not found: ${request.id}` };
390
+ const merged = request.meta === undefined ? undefined : { ...note.meta, ...request.meta };
391
+ session.dispatch({
392
+ type: "setNote",
393
+ id: request.id,
394
+ patch: {
395
+ ...(request.text === undefined ? {} : { text: request.text }),
396
+ ...(merged === undefined ? {} : { meta: merged }),
397
+ },
398
+ });
399
+ return { ok: true, result: session.getState().document.annotations.find((n) => n.id === request.id) };
400
+ }
401
+ case "set_meta": {
402
+ const target = findMetaTarget(session.getState().document, request.id);
403
+ if (target === null)
404
+ return { ok: false, error: `object not found: ${request.id}` };
405
+ const merged = { ...target.meta, ...request.patch };
406
+ const invalid = validateMetaForKind(target.kind, merged);
407
+ if (invalid !== null)
408
+ return { ok: false, error: invalid };
409
+ session.dispatch(target.target === "marker"
410
+ ? { type: "setMarker", id: request.id, patch: { meta: merged } }
411
+ : target.target === "volume"
412
+ ? { type: "setVolume", id: request.id, patch: { meta: merged } }
413
+ : target.target === "path"
414
+ ? { type: "setPath", id: request.id, patch: { meta: merged } }
415
+ : { type: "setNote", id: request.id, patch: { meta: merged } });
416
+ return { ok: true, result: { id: request.id, kind: target.kind, meta: merged } };
417
+ }
418
+ case "select":
419
+ session.dispatch({ type: "select", ids: request.ids });
420
+ return { ok: true, result: { selection: session.getState().selection } };
421
+ case "clear_selection":
422
+ session.dispatch({ type: "clearSelection" });
423
+ return { ok: true, result: { selection: [] } };
424
+ case "camera_goto": {
425
+ if (request.id !== undefined) {
426
+ const state = session.getState();
427
+ const marker = state.document.markers.find((m) => m.id === request.id);
428
+ const volume = state.document.volumes.find((v) => v.id === request.id);
429
+ const point = marker?.position ?? volume?.center;
430
+ if (point === undefined)
431
+ return { ok: false, error: `id not found: ${request.id}` };
432
+ api.setFocusTarget({ ...point });
433
+ session.dispatch({ type: "select", ids: [request.id] });
434
+ return { ok: true, result: { target: point } };
435
+ }
436
+ if (request.x === undefined || request.z === undefined) {
437
+ return { ok: false, error: "camera_goto requires id or x/z" };
438
+ }
439
+ const target = { x: request.x, y: request.y ?? 0, z: request.z };
440
+ api.setFocusTarget(target);
441
+ return { ok: true, result: { target } };
442
+ }
443
+ case "camera_frame": {
444
+ const bounds = editorDocumentBounds(session.getState().document);
445
+ if (bounds === null)
446
+ return { ok: false, error: "document is empty" };
447
+ const target = {
448
+ x: (bounds.min.x + bounds.max.x) / 2,
449
+ y: (bounds.min.y + bounds.max.y) / 2,
450
+ z: (bounds.min.z + bounds.max.z) / 2,
451
+ };
452
+ api.setFocusTarget(target);
453
+ return { ok: true, result: { target, bounds } };
454
+ }
455
+ case "scene_summary":
456
+ return {
457
+ ok: true,
458
+ result: {
459
+ gameId: options.gameId,
460
+ kinds: listEditorKinds(session.getState().document),
461
+ ...summarizeEditorSession(session.getState()),
462
+ bounds: editorDocumentBounds(session.getState().document),
463
+ },
464
+ };
465
+ case "export_document":
466
+ return { ok: true, result: { json: session.exportJson(true) } };
467
+ case "import_document":
468
+ session.dispatch({ type: "importJson", json: request.json });
469
+ return { ok: true, result: summarizeEditorSession(session.getState()) };
470
+ case "push_document_patch": {
471
+ const force = request.force === true;
472
+ const patch = request.patch;
473
+ if (!force && patch.baseRevision !== liveSync.getRevision()) {
474
+ return {
475
+ ok: false,
476
+ error: `baseRevision mismatch: patch=${patch.baseRevision} current=${liveSync.getRevision()}`,
477
+ };
478
+ }
479
+ if (patch.type === "snapshot") {
480
+ session.dispatch({ type: "replaceDocument", document: patch.document });
481
+ return {
482
+ ok: true,
483
+ result: {
484
+ revision: liveSync.getRevision(),
485
+ ...summarizeEditorSession(session.getState()),
486
+ },
487
+ };
488
+ }
489
+ if (patch.commands.length === 0)
490
+ return { ok: false, error: "commands patch is empty" };
491
+ for (const command of patch.commands) {
492
+ const { applied } = dispatchGuarded(command);
493
+ if (!applied && command.type !== "select" && command.type !== "clearSelection") {
494
+ return { ok: false, error: `${command.type} rejected while applying patch` };
495
+ }
496
+ }
497
+ return {
498
+ ok: true,
499
+ result: {
500
+ revision: liveSync.getRevision(),
501
+ ...summarizeEditorSession(session.getState()),
502
+ },
503
+ };
504
+ }
505
+ case "pull_document_patches":
506
+ return {
507
+ ok: true,
508
+ result: {
509
+ revision: liveSync.getRevision(),
510
+ patches: liveSync.pullPatches(request.sinceRevision ?? 0),
511
+ },
512
+ };
513
+ case "document_revision":
514
+ return {
515
+ ok: true,
516
+ result: {
517
+ revision: liveSync.getRevision(),
518
+ ...(request.includeDocument === true ? { document: liveSync.getDocument() } : {}),
519
+ },
520
+ };
521
+ case "push_runtime_delta": {
522
+ const delta = liveSync.pushRuntimeDelta({
523
+ at: request.at ?? Date.now(),
524
+ ...(request.entities === undefined ? {} : { entities: request.entities }),
525
+ ...(request.removeIds === undefined ? {} : { removeIds: request.removeIds }),
526
+ ...(request.tunables === undefined ? {} : { tunables: request.tunables }),
527
+ });
528
+ return { ok: true, result: delta };
529
+ }
530
+ case "pull_runtime_deltas":
531
+ return {
532
+ ok: true,
533
+ result: {
534
+ seq: liveSync.getRuntimeState().seq,
535
+ deltas: liveSync.pullRuntimeDeltas(request.sinceSeq ?? 0),
536
+ ...(request.includeSnapshot === true ? { snapshot: liveSync.getRuntimeState() } : {}),
537
+ },
538
+ };
539
+ case "runtime_snapshot":
540
+ return { ok: true, result: liveSync.getRuntimeState() };
541
+ case "runtime_summary":
542
+ return {
543
+ ok: true,
544
+ result: summarizeRuntimeInspector(liveSync.getRuntimeState(), liveSync.getRuntimeOverrides(), playControl),
545
+ };
546
+ case "runtime_get": {
547
+ if (request.id.length === 0)
548
+ return { ok: false, error: "runtime_get requires id" };
549
+ const got = getRuntimeInspectorValue(liveSync.getRuntimeState(), liveSync.getRuntimeOverrides(), request.id, request.path);
550
+ if (got.kind === "missing") {
551
+ return { ok: false, error: `runtime entity/tunable not found: ${request.id}` };
552
+ }
553
+ return { ok: true, result: got };
554
+ }
555
+ case "runtime_set": {
556
+ if (request.id.length === 0)
557
+ return { ok: false, error: "runtime_set requires id" };
558
+ const plan = planRuntimeInspectorSet(session.getState().document, {
559
+ id: request.id,
560
+ ...(request.path === undefined ? {} : { path: request.path }),
561
+ ...(request.value === undefined ? {} : { value: request.value }),
562
+ ...(request.position === undefined ? {} : { position: request.position }),
563
+ ...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
564
+ ...(request.values === undefined ? {} : { values: request.values }),
565
+ ...(request.writeBack === undefined ? {} : { writeBack: request.writeBack }),
566
+ });
567
+ if (plan.error !== undefined)
568
+ return { ok: false, error: plan.error };
569
+ if (plan.tunable !== undefined) {
570
+ liveSync.pushRuntimeDelta({
571
+ at: Date.now(),
572
+ tunables: { [plan.tunable.key]: plan.tunable.value },
573
+ });
574
+ return {
575
+ ok: true,
576
+ result: {
577
+ kind: "tunable",
578
+ key: plan.tunable.key,
579
+ value: plan.tunable.value,
580
+ writeBack: false,
581
+ ...summarizeRuntimeInspector(liveSync.getRuntimeState(), liveSync.getRuntimeOverrides(), playControl),
582
+ },
583
+ };
584
+ }
585
+ if (plan.entity === undefined)
586
+ return { ok: false, error: "runtime_set produced no entity" };
587
+ liveSync.setRuntimeOverride(plan.entity);
588
+ liveSync.pushRuntimeDelta({ at: Date.now(), entities: [plan.entity] });
589
+ let wroteBack = false;
590
+ let lastState = session.getState();
591
+ for (const command of plan.writeBackCommands) {
592
+ const { applied, state } = dispatchGuarded(command);
593
+ if (!applied) {
594
+ return { ok: false, error: `runtime_set write-back rejected: ${command.type}` };
595
+ }
596
+ lastState = state;
597
+ wroteBack = true;
598
+ }
599
+ if (wroteBack)
600
+ liveSync.clearRuntimeOverride(plan.entity.id);
601
+ return {
602
+ ok: true,
603
+ result: {
604
+ kind: "entity",
605
+ entity: plan.entity,
606
+ writeBack: wroteBack,
607
+ revision: liveSync.getRevision(),
608
+ ...summarizeEditorSession(lastState),
609
+ },
610
+ };
611
+ }
612
+ case "runtime_pause": {
613
+ api.setPlayControl({ paused: true, pendingSteps: 0 });
614
+ liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { paused: true } });
615
+ return { ok: true, result: { play: playControl } };
616
+ }
617
+ case "runtime_resume": {
618
+ api.setPlayControl({ paused: false, pendingSteps: 0 });
619
+ liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { paused: false } });
620
+ return { ok: true, result: { play: playControl } };
621
+ }
622
+ case "runtime_step": {
623
+ const frames = request.frames === undefined ? 1 : Math.max(1, Math.floor(request.frames));
624
+ api.setPlayControl({ paused: true, pendingSteps: playControl.pendingSteps + frames });
625
+ liveSync.pushRuntimeDelta({ at: Date.now(), tunables: { paused: true, pendingSteps: playControl.pendingSteps } });
626
+ return { ok: true, result: { play: playControl } };
627
+ }
628
+ case "set_runtime_override": {
629
+ liveSync.setRuntimeOverride(request.entity);
630
+ return { ok: true, result: { overrides: liveSync.getRuntimeOverrides() } };
631
+ }
632
+ case "clear_runtime_override": {
633
+ liveSync.clearRuntimeOverride(request.id);
634
+ return { ok: true, result: { overrides: liveSync.getRuntimeOverrides() } };
635
+ }
636
+ case "write_back_override": {
637
+ const entity = liveSync.getRuntimeOverrides()[request.id];
638
+ if (entity === undefined) {
639
+ return { ok: false, error: `no runtime override for "${request.id}"` };
640
+ }
641
+ const transform = runtimeEntityWriteBackCommand(session.getState().document, entity);
642
+ const meta = runtimeEntityMetaWriteBackCommand(session.getState().document, entity);
643
+ if (transform === null && meta === null) {
644
+ return {
645
+ ok: false,
646
+ error: `override "${request.id}" has nothing to write back into the document`,
647
+ };
648
+ }
649
+ let lastState = session.getState();
650
+ for (const command of [transform, meta]) {
651
+ if (command === null)
652
+ continue;
653
+ const { applied, state } = dispatchGuarded(command);
654
+ if (!applied)
655
+ return { ok: false, error: `write_back_override rejected for "${request.id}"` };
656
+ lastState = state;
657
+ }
658
+ liveSync.clearRuntimeOverride(request.id);
659
+ return {
660
+ ok: true,
661
+ result: {
662
+ revision: liveSync.getRevision(),
663
+ ...summarizeEditorSession(lastState),
664
+ },
665
+ };
666
+ }
667
+ case "dispatch": {
668
+ const { applied, state } = dispatchGuarded(request.command);
669
+ if (!applied)
670
+ return { ok: false, error: `${request.command.type} rejected: no effect` };
671
+ return { ok: true, result: summarizeEditorSession(state) };
672
+ }
673
+ case "undo": {
674
+ const { applied, state } = dispatchGuarded({ type: "undo" });
675
+ if (!applied)
676
+ return { ok: false, error: "nothing to undo" };
677
+ return { ok: true, result: summarizeEditorSession(state) };
678
+ }
679
+ case "redo": {
680
+ const { applied, state } = dispatchGuarded({ type: "redo" });
681
+ if (!applied)
682
+ return { ok: false, error: "nothing to redo" };
683
+ return { ok: true, result: summarizeEditorSession(state) };
684
+ }
685
+ case "list_assets":
686
+ return { ok: true, result: { assets } };
687
+ case "place_asset": {
688
+ const focus = focusTarget ?? { x: 0, y: 0, z: 0 };
689
+ const asset = assets.find((entry) => entry.id === request.id);
690
+ const placed = resolvePlaceAsset({
691
+ assetId: request.id,
692
+ position: {
693
+ x: request.x ?? focus.x,
694
+ y: request.y ?? focus.y,
695
+ z: request.z ?? focus.z,
696
+ },
697
+ kind: request.kind,
698
+ knownKind: asset?.kind,
699
+ knownLabel: asset?.label,
700
+ knownUrl: asset?.url,
701
+ });
702
+ const marker = toEditorMarker(placed);
703
+ session.dispatch({ type: "addMarker", marker });
704
+ return {
705
+ ok: true,
706
+ result: {
707
+ id: marker.id,
708
+ position: marker.position,
709
+ marker: session.getState().document.markers.find((entry) => entry.id === marker.id),
710
+ },
711
+ };
712
+ }
713
+ case "create_terrain": {
714
+ const width = request.width ?? 200;
715
+ const depth = request.depth ?? 200;
716
+ const cx = request.centerX ?? 0;
717
+ const cz = request.centerZ ?? 0;
718
+ const terrain = createTerrainSnapshot({
719
+ bounds: { minX: cx - width / 2, minZ: cz - depth / 2, maxX: cx + width / 2, maxZ: cz + depth / 2 },
720
+ cellSize: request.cellSize ?? 2,
721
+ });
722
+ session.dispatch({ type: "setTerrain", terrain });
723
+ return { ok: true, result: { cols: terrain.cols, rows: terrain.rows, cellSize: terrain.cellSize } };
724
+ }
725
+ case "sculpt_terrain": {
726
+ const terrain = session.getState().document.terrain;
727
+ if (terrain === undefined)
728
+ return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
729
+ const live = editableTerrainFromSnapshot(terrain);
730
+ const edit = {
731
+ mode: request.mode,
732
+ center: [request.x, request.z],
733
+ radius: request.radius ?? 8,
734
+ strength: request.strength ?? 1,
735
+ ...(request.target === undefined ? {} : { target: request.target }),
736
+ ...(request.toX === undefined || request.toZ === undefined ? {} : { to: [request.toX, request.toZ] }),
737
+ ...(request.seed === undefined ? {} : { seed: request.seed }),
738
+ ...(request.shape === undefined ? {} : { shape: request.shape }),
739
+ };
740
+ const delta = live.editDelta(edit);
741
+ if (delta.indices.length === 0)
742
+ return { ok: false, error: "brush touched no vertices (check radius/position)" };
743
+ session.dispatch({ type: "sculptTerrain", delta });
744
+ return { ok: true, result: { changed: delta.indices.length, canUndo: session.canUndo() } };
745
+ }
746
+ case "terrain_summary": {
747
+ const terrain = session.getState().document.terrain;
748
+ if (terrain === undefined)
749
+ return { ok: true, result: { terrain: null } };
750
+ let min = Infinity;
751
+ let max = -Infinity;
752
+ let nonZero = 0;
753
+ for (const value of terrain.offsets) {
754
+ if (value < min)
755
+ min = value;
756
+ if (value > max)
757
+ max = value;
758
+ if (value !== 0)
759
+ nonZero += 1;
760
+ }
761
+ return {
762
+ ok: true,
763
+ result: {
764
+ cols: terrain.cols,
765
+ rows: terrain.rows,
766
+ cellSize: terrain.cellSize,
767
+ bounds: terrain.bounds,
768
+ minOffset: terrain.offsets.length === 0 ? 0 : min,
769
+ maxOffset: terrain.offsets.length === 0 ? 0 : max,
770
+ editedVertices: nonZero,
771
+ paintedCells: terrain.surfaces.filter((surface) => surface !== null).length,
772
+ },
773
+ };
774
+ }
775
+ case "paint_terrain": {
776
+ const terrain = session.getState().document.terrain;
777
+ if (terrain === undefined)
778
+ return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
779
+ const live = editableTerrainFromSnapshot(terrain);
780
+ const delta = live.paintDelta({
781
+ mode: "paint",
782
+ center: [request.x, request.z],
783
+ radius: request.radius ?? 8,
784
+ surface: request.surface,
785
+ ...(request.shape === undefined ? {} : { shape: request.shape }),
786
+ });
787
+ if (delta.indices.length === 0)
788
+ return { ok: false, error: "paint touched no cells (check radius/position)" };
789
+ session.dispatch({ type: "paintTerrain", delta });
790
+ return { ok: true, result: { changed: delta.indices.length, canUndo: session.canUndo() } };
791
+ }
792
+ case "fill_terrain": {
793
+ const terrain = session.getState().document.terrain;
794
+ if (terrain === undefined)
795
+ return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
796
+ const live = editableTerrainFromSnapshot(terrain);
797
+ const delta = live.fillSurfaceDelta(request.surface);
798
+ if (delta.indices.length === 0)
799
+ return { ok: true, result: { changed: 0 } };
800
+ session.dispatch({ type: "paintTerrain", delta });
801
+ return { ok: true, result: { changed: delta.indices.length } };
802
+ }
803
+ case "auto_paint": {
804
+ const terrain = session.getState().document.terrain;
805
+ if (terrain === undefined)
806
+ return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
807
+ const live = editableTerrainFromSnapshot(terrain);
808
+ const rule = {
809
+ surface: request.surface,
810
+ ...(request.minSlope === undefined ? {} : { minSlope: request.minSlope }),
811
+ ...(request.maxSlope === undefined ? {} : { maxSlope: request.maxSlope }),
812
+ ...(request.minHeight === undefined ? {} : { minHeight: request.minHeight }),
813
+ ...(request.maxHeight === undefined ? {} : { maxHeight: request.maxHeight }),
814
+ };
815
+ const delta = live.autoPaintDelta(rule);
816
+ if (delta.indices.length === 0)
817
+ return { ok: true, result: { changed: 0 } };
818
+ session.dispatch({ type: "paintTerrain", delta });
819
+ return { ok: true, result: { changed: delta.indices.length } };
820
+ }
821
+ case "terrain_materials":
822
+ return { ok: true, result: { materials: TERRAIN_MATERIALS } };
823
+ case "terrain_layers": {
824
+ const terrain = session.getState().document.terrain;
825
+ return { ok: true, result: { layers: terrain?.layers ?? [] } };
826
+ }
827
+ case "set_terrain_layers": {
828
+ if (session.getState().document.terrain === undefined) {
829
+ return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
830
+ }
831
+ session.dispatch({ type: "setTerrainLayers", layers: request.layers });
832
+ return { ok: true, result: { layers: session.getState().document.terrain?.layers ?? [] } };
833
+ }
834
+ case "blend_terrain": {
835
+ const terrain = session.getState().document.terrain;
836
+ if (terrain === undefined)
837
+ return { ok: false, error: "no terrain ΓÇö call create_terrain first" };
838
+ // Auto-add the surface as a layer if the stack does not carry it yet.
839
+ const layers = terrain.layers ?? [];
840
+ if (!layers.some((layer) => layer.surface === request.surface)) {
841
+ const next = [...layers, { id: request.surface, surface: request.surface }];
842
+ session.dispatch({ type: "setTerrainLayers", layers: next });
843
+ }
844
+ const live = editableTerrainFromSnapshot(session.getState().document.terrain);
845
+ const delta = live.blendPaintDelta({
846
+ mode: "paint",
847
+ center: [request.x, request.z],
848
+ radius: request.radius ?? 8,
849
+ surface: request.surface,
850
+ strength: request.strength ?? 1,
851
+ ...(request.shape === undefined ? {} : { shape: request.shape }),
852
+ });
853
+ if (delta.indices.length === 0)
854
+ return { ok: false, error: "blend touched no cells (check radius/position)" };
855
+ session.dispatch({ type: "blendTerrain", delta });
856
+ return { ok: true, result: { changed: delta.indices.length, layers: session.getState().document.terrain?.layers ?? [] } };
857
+ }
858
+ case "convert_scatter": {
859
+ const doc = session.getState().document;
860
+ const path = doc.paths.find((entry) => entry.id === request.pathId);
861
+ if (path === undefined)
862
+ return { ok: false, error: `path not found: ${request.pathId}` };
863
+ const region = scatterRegionFromPath(path);
864
+ if (region === null)
865
+ return { ok: false, error: `not a scatter region: ${request.pathId}` };
866
+ const terrainSnapshot = doc.terrain;
867
+ const terrain = terrainSnapshot === undefined ? undefined : editableTerrainFromSnapshot(terrainSnapshot);
868
+ const instances = resolveScatterRegion(region, terrain);
869
+ const markers = instances.map((instance) => ({
870
+ id: instance.id.replace(/[^a-zA-Z0-9_]/g, "_"),
871
+ kind: "prop",
872
+ position: { x: instance.x, y: instance.y, z: instance.z },
873
+ rotationY: instance.rotationY,
874
+ meta: { item: instance.item, scale: instance.scale, fromScatter: request.pathId },
875
+ }));
876
+ session.dispatch({ type: "convertScatterToObjects", pathId: request.pathId, markers });
877
+ return { ok: true, result: { created: markers.length, removedPath: request.pathId } };
878
+ }
879
+ case "add_foliage": {
880
+ if (request.points.length < 3)
881
+ return { ok: false, error: "add_foliage needs at least 3 polygon points" };
882
+ const id = `foliage_${Date.now().toString(36)}`;
883
+ session.dispatch({
884
+ type: "addPath",
885
+ path: {
886
+ id,
887
+ kind: SCATTER_PATH_KIND,
888
+ points: request.points.map((point) => ({ x: point.x, y: 0, z: point.z })),
889
+ label: "foliage",
890
+ meta: {
891
+ density: request.density ?? 0.15,
892
+ ...(request.item === undefined ? {} : { item: request.item }),
893
+ ...(request.seed === undefined ? {} : { seed: request.seed }),
894
+ ...(request.minSpacing === undefined ? {} : { minSpacing: request.minSpacing }),
895
+ },
896
+ },
897
+ });
898
+ const path = session.getState().document.paths.find((p) => p.id === id);
899
+ return { ok: true, result: { id, estimate: scatterRegionEstimate(path) } };
900
+ }
901
+ case "scatter_summary": {
902
+ const doc = session.getState().document;
903
+ const regions = doc.paths.filter((path) => path.kind === SCATTER_PATH_KIND).length;
904
+ const instances = resolveScatter(doc).length;
905
+ return { ok: true, result: { regions, instances } };
906
+ }
907
+ case "set_parent": {
908
+ const { applied, state } = dispatchGuarded({
909
+ type: "setParent",
910
+ ids: request.ids,
911
+ parentId: request.parentId,
912
+ });
913
+ if (!applied)
914
+ return { ok: false, error: "set_parent rejected: cyclic parent or empty selection" };
915
+ const doc = state.document;
916
+ return {
917
+ ok: true,
918
+ result: { roots: editorRoots(doc), parents: request.ids.map((id) => ({ id, parentId: editorParentOf(doc, id) ?? null })) },
919
+ };
920
+ }
921
+ case "hierarchy": {
922
+ const doc = session.getState().document;
923
+ const roots = editorRoots(doc);
924
+ return {
925
+ ok: true,
926
+ result: { roots, tree: roots.map((id) => ({ id, children: editorChildren(doc, id) })) },
927
+ };
928
+ }
929
+ case "list_prefabs":
930
+ return { ok: true, result: { prefabs: session.getState().document.prefabs } };
931
+ case "create_prefab": {
932
+ if (request.ids.length === 0)
933
+ return { ok: false, error: "create_prefab needs at least one id" };
934
+ session.dispatch({ type: "createPrefab", id: request.id, name: request.name, ids: request.ids });
935
+ const prefab = findEditorPrefab(session.getState().document, request.id);
936
+ return prefab === undefined
937
+ ? { ok: false, error: `prefab not created: ${request.id}` }
938
+ : { ok: true, result: prefab };
939
+ }
940
+ case "insert_prefab": {
941
+ const prefab = findEditorPrefab(session.getState().document, request.prefabId);
942
+ if (prefab === undefined)
943
+ return { ok: false, error: `prefab not found: ${request.prefabId}` };
944
+ const at = focusTarget ?? { x: 0, y: 0, z: 0 };
945
+ session.dispatch({
946
+ type: "insertPrefab",
947
+ prefabId: request.prefabId,
948
+ at: { x: request.x ?? at.x, y: request.y ?? at.y, z: request.z ?? at.z },
949
+ });
950
+ return { ok: true, result: summarizeEditorSession(session.getState()) };
951
+ }
952
+ case "detach_prefab_instance":
953
+ session.dispatch({ type: "detachPrefabInstance", instanceId: request.instanceId });
954
+ return { ok: true, result: summarizeEditorSession(session.getState()) };
955
+ case "delete_prefab":
956
+ session.dispatch({ type: "deletePrefab", prefabId: request.prefabId });
957
+ return { ok: true, result: { prefabs: session.getState().document.prefabs } };
958
+ case "list_collections":
959
+ return { ok: true, result: { collections: session.getState().document.collections } };
960
+ case "create_collection":
961
+ session.dispatch({
962
+ type: "createCollection",
963
+ id: request.id,
964
+ name: request.name,
965
+ ...(request.memberIds === undefined ? {} : { memberIds: request.memberIds }),
966
+ });
967
+ return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
968
+ case "rename_collection":
969
+ session.dispatch({ type: "renameCollection", id: request.id, name: request.name });
970
+ return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
971
+ case "delete_collection":
972
+ session.dispatch({ type: "deleteCollection", id: request.id });
973
+ return { ok: true, result: { collections: session.getState().document.collections } };
974
+ case "set_collection_members":
975
+ session.dispatch({ type: "setCollectionMembers", id: request.id, memberIds: request.memberIds });
976
+ return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
977
+ case "add_to_collection":
978
+ session.dispatch({ type: "addToCollection", id: request.id, ids: request.ids });
979
+ return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
980
+ case "remove_from_collection":
981
+ session.dispatch({ type: "removeFromCollection", id: request.id, ids: request.ids });
982
+ return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
983
+ case "set_collection_flags":
984
+ session.dispatch({
985
+ type: "setCollectionFlags",
986
+ id: request.id,
987
+ patch: {
988
+ ...(request.color === undefined ? {} : { color: request.color }),
989
+ ...(request.locked === undefined ? {} : { locked: request.locked }),
990
+ ...(request.visible === undefined ? {} : { visible: request.visible }),
991
+ },
992
+ });
993
+ return { ok: true, result: findEditorCollection(session.getState().document, request.id) };
994
+ case "select_collection": {
995
+ const collection = findEditorCollection(session.getState().document, request.id);
996
+ if (collection === undefined)
997
+ return { ok: false, error: `collection not found: ${request.id}` };
998
+ session.dispatch({ type: "selectCollection", id: request.id });
999
+ return { ok: true, result: { selection: session.getState().selection } };
1000
+ }
1001
+ case "batch_set_properties":
1002
+ session.dispatch({
1003
+ type: "batchSetProperties",
1004
+ ids: request.ids,
1005
+ patch: {
1006
+ ...(request.color === undefined ? {} : { color: request.color }),
1007
+ ...(request.label === undefined ? {} : { label: request.label }),
1008
+ ...(request.meta === undefined ? {} : { meta: request.meta }),
1009
+ },
1010
+ });
1011
+ return { ok: true, result: summarizeEditorSession(session.getState()) };
1012
+ case "assign_material":
1013
+ session.dispatch({ type: "assignMaterial", ids: request.ids, materialId: request.materialId });
1014
+ return { ok: true, result: summarizeEditorSession(session.getState()) };
1015
+ }
1016
+ }
1017
+ catch (error) {
1018
+ return {
1019
+ ok: false,
1020
+ error: error instanceof Error ? error.message : String(error),
1021
+ };
1022
+ }
1023
+ },
1024
+ };
1025
+ const uninstallHost = installEditorHost(api);
1026
+ const dispose = () => {
1027
+ unsubscribeSessionMirror();
1028
+ uninstallLiveSync();
1029
+ uninstallHost();
1030
+ };
1031
+ return { session, api, dispose };
1032
+ }