@vgai/editor-sdk 0.5.13 → 0.5.15

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/editor-sdk",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.13",
5
+ "version": "0.5.15",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@types/three": "^0.180.0",
26
- "@vgai/sdk": "0.5.13"
26
+ "@vgai/sdk": "0.5.15"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@vgai/engine": "*",
package/src/client.ts CHANGED
@@ -2,6 +2,7 @@ import type { GenerationJobsDocument } from '@vgai/sdk/generations';
2
2
  import type { DocumentProbeResult, DocumentProbeStep } from './document-probe.js';
3
3
  import type {
4
4
  ActiveDocumentCapture,
5
+ AnimationCaptureAction,
5
6
  AssetCompareCapture,
6
7
  AssetCompareOptions,
7
8
  AssetKind,
@@ -12,7 +13,11 @@ import type {
12
13
  EditorState,
13
14
  EditorView,
14
15
  GameCapture,
16
+ GameplayRecordingCapture,
17
+ GameplayRecordingOptions,
18
+ GameplayRecordingStarted,
15
19
  HelperVisibility,
20
+ InspectedFieldWrite,
16
21
  InspectedHierarchy,
17
22
  InspectedInspection,
18
23
  LabeledShotSetCapture,
@@ -46,7 +51,7 @@ const DEFAULT_URL = 'http://127.0.0.1:20173';
46
51
  * `/__editor/command` has always answered `{ ok: false, error, code }` and
47
52
  * this client has always dropped the `code` on the floor, so every caller that
48
53
  * wanted to react to a specific refusal had to substring-match an English
49
- * sentence. `vgai screenshot`'s hidden-tab fallback is the first caller that
54
+ * sentence. `vgai screenshot`'s loop-recovery fallback is the first caller that
50
55
  * genuinely must branch (`BRIDGE_SCREENSHOT_STALE` has a working recovery;
51
56
  * "not in play mode" does not), and a fallback keyed on prose would fire on
52
57
  * the wrong failure the first time someone rewords the message.
@@ -148,19 +153,36 @@ export class EditorClient {
148
153
  */
149
154
  readonly game: GameDebugDoor;
150
155
 
151
- constructor(opts?: { url?: string }) {
156
+ /**
157
+ * Called with the raw body of EVERY response this client receives — command
158
+ * envelopes and `/__editor/state` alike, on success AND on refusal.
159
+ *
160
+ * It exists for exactly one contract: the server stamps `unresolvedConsole`
161
+ * onto every envelope (`server-utils.ts`'s `commandResponseFor`), and the CLI
162
+ * has to see those counts to be loud about them. Routing that through a
163
+ * single observer here — rather than teaching each of the CLI's output sites
164
+ * to unpack a response — is what keeps the loudness contract ONE mechanism.
165
+ * The observer must not throw; anything it raises is swallowed, because a
166
+ * reporting hook may never break the command it is reporting on.
167
+ */
168
+ private readonly onEnvelope: ((body: unknown) => void) | null;
169
+
170
+ constructor(opts?: { url?: string; onEnvelope?: (body: unknown) => void }) {
152
171
  if (opts !== undefined && (typeof opts !== 'object' || opts === null || Array.isArray(opts))) {
153
172
  throw new TypeError(
154
173
  'EditorClient options must be an object. Use new EditorClient({ url: "http://127.0.0.1:20173" }), not new EditorClient("...").',
155
174
  );
156
175
  }
157
- const unknownOptions = Object.keys(opts ?? {}).filter((key) => key !== 'url');
176
+ const unknownOptions = Object.keys(opts ?? {}).filter(
177
+ (key) => key !== 'url' && key !== 'onEnvelope',
178
+ );
158
179
  if (unknownOptions.length > 0) {
159
180
  throw new Error(
160
181
  `EditorClient: unknown option${unknownOptions.length === 1 ? '' : 's'} ${unknownOptions.map((key) => `"${key}"`).join(', ')}. Use { url: "http://127.0.0.1:<port>" } to target an editor.`,
161
182
  );
162
183
  }
163
184
  this.baseUrl = (opts?.url ?? DEFAULT_URL).replace(/\/$/, '');
185
+ this.onEnvelope = opts?.onEnvelope ?? null;
164
186
  this.game = {
165
187
  state: async (name: string): Promise<unknown> => {
166
188
  // `keys` narrows the relay to the one provider asked for, so a panel
@@ -202,6 +224,7 @@ export class EditorClient {
202
224
  );
203
225
  }
204
226
  const data = (await res.json()) as { ok: boolean; error?: string; code?: string } & T;
227
+ this.observe(data);
205
228
  if (!data.ok) {
206
229
  throw new EditorCommandError(
207
230
  data.error ?? `Editor command failed: ${res.status}`,
@@ -329,13 +352,13 @@ export class EditorClient {
329
352
  * debug seam") or no game canvas is mounted yet. Never returns a blank or
330
353
  * editor-only frame as a stand-in.
331
354
  *
332
- * `opts.refreshHiddenFrame` is the hidden-tab leg: a backgrounded tab
333
- * pauses the loop, so the canvas holds a provably stale frame and the relay
355
+ * `opts.refreshStarvedFrame` is the loop-starvation leg: without recent rAF
356
+ * progress the canvas holds a provably stale frame and the relay
334
357
  * refuses it with `BRIDGE_SCREENSHOT_STALE` rather than pass it off as
335
358
  * current. Setting this asks the relay to render exactly ONE deterministic
336
359
  * tick (`runTicks(1, {render:'last'})`) first — the same escape
337
360
  * `@vgai/live`'s `RelayTransport.screenshot` has always used, which is why
338
- * `vgai eval` could capture hidden frames while `vgai screenshot` could not.
361
+ * `vgai eval` could recover these frames while `vgai screenshot` could not.
339
362
  * Off by default: a caller who does not ask must never be handed a frame
340
363
  * that only exists because the capture drove the game.
341
364
  *
@@ -346,12 +369,12 @@ export class EditorClient {
346
369
  * worthless — so the contamination only happens when someone asks for it.
347
370
  */
348
371
  async captureGame(opts?: {
349
- refreshHiddenFrame?: boolean;
372
+ refreshStarvedFrame?: boolean;
350
373
  devLayers?: boolean;
351
374
  }): Promise<GameCapture> {
352
375
  const data = await this.command<GameCapture>({
353
376
  type: 'bridge-screenshot',
354
- ...(opts?.refreshHiddenFrame === true ? { refreshHiddenFrame: true } : {}),
377
+ ...(opts?.refreshStarvedFrame === true ? { refreshStarvedFrame: true } : {}),
355
378
  ...(opts?.devLayers === true ? { devLayers: true } : {}),
356
379
  });
357
380
  const layers = data.layers;
@@ -367,10 +390,28 @@ export class EditorClient {
367
390
  // warning sentence is written where the pixels are, so nothing here
368
391
  // re-derives (or softens) it.
369
392
  ...(flatness && typeof flatness.dominantFraction === 'number' ? { flatness } : {}),
370
- ...(data.hiddenFrame === true ? { hiddenFrame: true } : {}),
393
+ ...(data.loopRecoveryFrame === true ? { loopRecoveryFrame: true } : {}),
371
394
  };
372
395
  }
373
396
 
397
+ /** Start recording the same clean running-game composite `captureGame`
398
+ * photographs. Recording state lives in the editor page, so another process
399
+ * may stop it later through the same project session. */
400
+ async startGameplayRecording(
401
+ options: GameplayRecordingOptions = {},
402
+ ): Promise<GameplayRecordingStarted> {
403
+ return this.command<GameplayRecordingStarted>({
404
+ type: 'bridge-recording-start',
405
+ ...(options.fps !== undefined ? { fps: options.fps } : {}),
406
+ ...(options.devLayers === true ? { devLayers: true } : {}),
407
+ });
408
+ }
409
+
410
+ /** Stop the page-owned recorder and return its WebM path and metadata. */
411
+ async stopGameplayRecording(): Promise<GameplayRecordingCapture> {
412
+ return this.command<GameplayRecordingCapture>({ type: 'bridge-recording-stop' });
413
+ }
414
+
374
415
  /**
375
416
  * Capture an isolated, deterministic four-view preview through the editor's
376
417
  * native Asset Lab. The SDK delegates rendering to the editor; it never
@@ -572,14 +613,21 @@ export class EditorClient {
572
613
  return data.hierarchy;
573
614
  }
574
615
 
575
- /** Write one editable path through the active Inspector's own IO. */
576
- async setInspectionField(path: string, value: unknown): Promise<InspectedInspection> {
577
- const data = await this.command<{ subject: InspectedInspection }>({
616
+ /**
617
+ * Write one editable path through the active Inspector's own IO.
618
+ *
619
+ * The answer carries `write` as well as the subject, because an ack alone
620
+ * cannot be believed: a write with no persistence route open succeeds and
621
+ * changes no byte, and `write.persisted` is how the caller tells the two
622
+ * apart without diffing the tree (`InspectedWriteDestination` in `types.ts`).
623
+ */
624
+ async setInspectionField(path: string, value: unknown): Promise<InspectedFieldWrite> {
625
+ const data = await this.command<InspectedFieldWrite>({
578
626
  type: 'set-inspection-field',
579
627
  path,
580
628
  value,
581
629
  });
582
- return data.subject;
630
+ return { subject: data.subject, write: data.write };
583
631
  }
584
632
 
585
633
  /**
@@ -622,21 +670,6 @@ export class EditorClient {
622
670
 
623
671
  // --- Display (set semantics) ---
624
672
 
625
- /**
626
- * The Game document's "Persist to game source" consent, over the relay —
627
- * the same session-scoped switch the checkbox flips, refused for the same
628
- * reasons (it answers with the server's own words when the base cannot be
629
- * written). `recorder` names who accounts for the diff a write produces.
630
- */
631
- async setSourcePersistConsent(
632
- enabled: boolean,
633
- ): Promise<{ enabled: boolean; recorder: string | null }> {
634
- return this.command<{ enabled: boolean; recorder: string | null }>({
635
- type: 'set-source-persist-consent',
636
- enabled,
637
- });
638
- }
639
-
640
673
  async setGrid(enabled: boolean): Promise<void> {
641
674
  await this.command({ type: 'set-grid', enabled });
642
675
  }
@@ -653,6 +686,11 @@ export class EditorClient {
653
686
  await this.command({ type: 'set-shading-mode', mode });
654
687
  }
655
688
 
689
+ /** Drive the recorder owned by the active native animation document. */
690
+ async recordAnimation(action: AnimationCaptureAction): Promise<void> {
691
+ await this.command({ type: 'animation-capture', action });
692
+ }
693
+
656
694
  async setHelperType(helperType: keyof HelperVisibility, enabled: boolean): Promise<void> {
657
695
  await this.command({ type: 'set-helper-type', helperType, enabled });
658
696
  }
@@ -800,7 +838,19 @@ export class EditorClient {
800
838
  async getState(): Promise<EditorState> {
801
839
  const res = await fetch(`${this.baseUrl}/__editor/state`);
802
840
  if (!res.ok) throw new Error(`Failed to get editor state: ${res.status} ${res.statusText}`);
803
- return (await res.json()) as EditorState;
841
+ const state = (await res.json()) as EditorState;
842
+ this.observe(state);
843
+ return state;
844
+ }
845
+
846
+ /** Hand one response body to {@link onEnvelope}, never letting it throw. */
847
+ private observe(body: unknown): void {
848
+ if (this.onEnvelope === null) return;
849
+ try {
850
+ this.onEnvelope(body);
851
+ } catch {
852
+ // A reporting hook may never break the command it is reporting on.
853
+ }
804
854
  }
805
855
 
806
856
  /**
@@ -41,6 +41,15 @@ export interface ToolContributionNode {
41
41
  export interface ToolObject3DPreviewSource {
42
42
  readonly root: Object3D;
43
43
  readonly animations?: readonly AnimationClip[];
44
+ /**
45
+ * The source's OWN tick, and the host drives it EXACTLY ONCE per mount — for
46
+ * the bounded design-time settle that gives a physics-owned body the pose it
47
+ * actually rests in (`authoring/design-time-settle.ts`). It is never a frame
48
+ * loop: content time does not advance on a design-time surface, so a source
49
+ * that animates from here shows its settled pose and then holds it. Play is
50
+ * where a world runs.
51
+ */
52
+ update?(deltaSeconds: number): void;
44
53
  dispose(): void;
45
54
  }
46
55
 
@@ -73,7 +82,6 @@ export interface ToolObject3DPreviewProps {
73
82
  readonly height?: number | string;
74
83
  readonly background?: ColorRepresentation;
75
84
  readonly showSkeleton?: boolean;
76
- readonly autoplay?: boolean;
77
85
  readonly cameraDirection?: readonly [number, number, number];
78
86
  /** Host diagnostic grid. Defaults off with setupPreview, on otherwise. */
79
87
  readonly showGrid?: boolean;
@@ -124,7 +132,6 @@ export interface ToolObject3DAuthoringProps {
124
132
  * viewport dressing (environment, gradient backdrop, key light). */
125
133
  readonly background?: ColorRepresentation;
126
134
  readonly cameraDirection?: readonly [number, number, number];
127
- readonly autoplay?: boolean;
128
135
  /**
129
136
  * Optional binding from live native clips back to ordinary project source.
130
137
  * Project code only serializes its own TypeScript shape; the editor owns the
@@ -280,8 +287,29 @@ export interface ToolAnimationSourceBinding {
280
287
  readonly serialize: (animations: readonly AnimationClip[]) => string;
281
288
  }
282
289
 
290
+ /** Format-neutral identity for a project-owned Asset Lab document.
291
+ *
292
+ * This surface deliberately accepts no React children. Project tools may run
293
+ * a different React major than the editor, so their native UI stays in their
294
+ * own runtime while this editor-owned subject publishes the standard Asset
295
+ * Lab context, selection floor, and Inspector identity row beside it.
296
+ */
297
+ export interface ToolAssetDocumentProps {
298
+ /** Stable id of the open workspace document. */
299
+ readonly documentId: string;
300
+ /** Subject name shown by the Inspector. */
301
+ readonly title: string;
302
+ /** Ecosystem-neutral asset kind shown by the Inspector. */
303
+ readonly type: string;
304
+ /** Optional provenance or concise state shown with the identity row. */
305
+ readonly status?: string;
306
+ /** Whether this is the active center document. */
307
+ readonly active?: boolean;
308
+ }
309
+
283
310
  /** Editor-owned native surfaces available to ordinary project React tools. */
284
311
  export interface ToolContributionSurfaces {
312
+ readonly AssetDocument: import('react').ComponentType<ToolAssetDocumentProps>;
285
313
  readonly Object3DPreview: import('react').ComponentType<ToolObject3DPreviewProps>;
286
314
  readonly Object3DAuthoring: import('react').ComponentType<ToolObject3DAuthoringProps>;
287
315
  }
@@ -1,7 +1,9 @@
1
1
  import type { AssetKind, EditorCameraState, EditorView, ViewPreset } from './types.js';
2
+ import { EDITOR_VIEW_WORKSPACE_DOCUMENT_IDS } from './types.js';
2
3
 
3
4
  const PREFIX = 'view.';
4
5
  const MAX_QUERY_LENGTH = 4096;
6
+ const WORKSPACE_DOCUMENT_IDS = new Set<string>(EDITOR_VIEW_WORKSPACE_DOCUMENT_IDS);
5
7
  const ASSET_KINDS = new Set<AssetKind>([
6
8
  'model',
7
9
  'image',
@@ -147,16 +149,7 @@ function parseDocument(params: URLSearchParams): EditorView['document'] {
147
149
  if (kind === 'project-tool') return { kind, name: value };
148
150
  if (kind === 'generation') return { kind, id: value };
149
151
  if (kind === 'story') return parseStoryDocument(params, value);
150
- if (
151
- kind === 'workspace' &&
152
- [
153
- 'workspace:scene',
154
- 'workspace:game',
155
- 'workspace:build-profiles',
156
- 'account',
157
- 'project-tools',
158
- ].includes(value)
159
- ) {
152
+ if (kind === 'workspace' && WORKSPACE_DOCUMENT_IDS.has(value)) {
160
153
  return {
161
154
  kind,
162
155
  id: value as Extract<NonNullable<EditorView['document']>, { kind: 'workspace' }>['id'],
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export { editorViewFromUrl, editorViewUrl } from './editor-view.js';
29
29
  export type { ExtensionContributionState } from './extension.js';
30
30
  export type {
31
31
  ActiveDocumentCapture,
32
+ AnimationCaptureAction,
32
33
  AssetCompareCapture,
33
34
  AssetCompareOptions,
34
35
  AssetCompareView,
@@ -47,9 +48,13 @@ export type {
47
48
  EditorView,
48
49
  EditorViewDocument,
49
50
  GameCapture,
51
+ GameplayRecordingCapture,
52
+ GameplayRecordingOptions,
53
+ GameplayRecordingStarted,
50
54
  HelperVisibility,
51
55
  InspectedAction,
52
56
  InspectedField,
57
+ InspectedFieldWrite,
53
58
  InspectedHierarchy,
54
59
  InspectedHierarchyRow,
55
60
  InspectedInspection,
@@ -57,6 +62,7 @@ export type {
57
62
  InspectedSection,
58
63
  InspectedSectionBody,
59
64
  InspectedSubject,
65
+ InspectedWriteDestination,
60
66
  InspectionPresentationKind,
61
67
  InspectionSurface,
62
68
  LabeledShotSetCapture,
package/src/types.ts CHANGED
@@ -25,6 +25,7 @@ export interface HelperVisibility {
25
25
  cameras: boolean;
26
26
  colliders: boolean;
27
27
  joints: boolean;
28
+ particles: boolean;
28
29
  lod: boolean;
29
30
  audio: boolean;
30
31
  splines: boolean;
@@ -72,10 +73,10 @@ export interface ViewportCapture {
72
73
  * never a silently HUD-less image passed off as the whole game. `layers` is
73
74
  * only present on the composite leg.
74
75
  *
75
- * `flatness` and `hiddenFrame` are the same honesty contract applied to the
76
- * PIXELS: how much of the frame is one flat surface (a near-blank capture
76
+ * `flatness` and `loopRecoveryFrame` are the same honesty contract applied to
77
+ * the PIXELS: how much of the frame is one flat surface (a near-blank capture
77
78
  * carries its own "weak evidence" warning), and whether the frame exists only
78
- * because the capture asked a hidden tab's runtime for one deterministic tick.
79
+ * because capture recovered a starved host loop with one deterministic tick.
79
80
  */
80
81
  export interface GameCapture {
81
82
  base64: string;
@@ -92,9 +93,38 @@ export interface GameCapture {
92
93
  /** Present iff `degenerate`; the sentence to show the caller verbatim. */
93
94
  warning?: string;
94
95
  };
95
- /** True when the editor tab was hidden-paused and the runtime rendered one
96
+ /** True when the host loop was starved and the runtime rendered one
96
97
  * deterministic tick to produce this frame. */
97
- hiddenFrame?: boolean;
98
+ loopRecoveryFrame?: boolean;
99
+ }
100
+
101
+ /** Options for recording the clean running-game composite. */
102
+ export interface GameplayRecordingOptions {
103
+ /** Requested real-time capture cadence. Defaults to 30; range 1–60. */
104
+ fps?: number;
105
+ /** Include `dev: true` game layers. Off by default so ordinary evidence is
106
+ * the player-visible game, not its debugging overlay. */
107
+ devLayers?: boolean;
108
+ }
109
+
110
+ /** Facts fixed when a gameplay recording starts. */
111
+ export interface GameplayRecordingStarted {
112
+ startedAt: string;
113
+ mimeType: string;
114
+ width: number;
115
+ height: number;
116
+ fps: number;
117
+ audio: boolean;
118
+ layers: { canvases: number; domOverlays: number };
119
+ }
120
+
121
+ /** Final browser recording. Media chunks stream directly to the project while
122
+ * recording, so the command result stays small even for a long playthrough. */
123
+ export interface GameplayRecordingCapture extends GameplayRecordingStarted {
124
+ path: string;
125
+ durationMs: number;
126
+ droppedFrames: number;
127
+ frameErrors: number;
98
128
  }
99
129
 
100
130
  export type AssetPreviewView = 'front' | 'right' | 'top' | 'perspective';
@@ -307,13 +337,14 @@ export interface EditorState {
307
337
  * Issue #175 — the REAL engine `GameLoop.liveness` behind the current play
308
338
  * session, distinct from `playState` above (editor UI state — a store
309
339
  * flag that never reflected whether the loop was actually ticking).
310
- * `'hidden-paused'` means the T2.1 idle throttle stopped the loop because
311
- * the editor tab is backgrounded deliberate and reversible, but the
312
- * game is advancing ZERO ticks right now even though `playState` still
313
- * reads `'playing'`. `null` while not in play mode (no loop to report on)
314
- * or against an older server that predates this field.
340
+ * `'loop-starved'` means the host loop has observed no recent rAF progress:
341
+ * either the current `document.hidden` gate deliberately parked it, or an
342
+ * armed visible-page callback has not arrived within the starvation
343
+ * interval. It is explicitly NOT a conclusion that the tab is hidden.
344
+ * `null` while not in play mode (no loop to report on) or against an older
345
+ * server that predates this field.
315
346
  */
316
- loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null;
347
+ loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null;
317
348
  /**
318
349
  * The connected editor tab's OWN reported visibility/focus
319
350
  * (`command-listener.ts`'s `collectPresence`, straight off
@@ -323,8 +354,20 @@ export interface EditorState {
323
354
  * BACKGROUNDED tab satisfies perfectly while the engine hidden-pauses its
324
355
  * loop underneath. `null` when the page has no `document` at all; absent
325
356
  * against an older server that predates the field.
357
+ *
358
+ * P21 — `reportedAt` is `Date.now()` in the TAB at the moment those two
359
+ * values were read. It is what makes this snapshot readable as a
360
+ * measurement rather than a fact: the tab re-POSTs state on
361
+ * visibilitychange/focus/blur, after commands and on store changes, so
362
+ * between those moments this ages, and a reader with no age has no way to
363
+ * tell a 40ms-old reading from a 40s-old one. Absent against an editor
364
+ * page that predates the field — never fabricated from the read time.
326
365
  */
327
- presence?: { visibility: 'visible' | 'hidden' | 'prerender'; focused: boolean } | null;
366
+ presence?: {
367
+ visibility: 'visible' | 'hidden' | 'prerender';
368
+ focused: boolean;
369
+ reportedAt?: number;
370
+ } | null;
328
371
  /**
329
372
  * The pending-restart reason when source changed while the game was
330
373
  * RUNNING and the running session is now stale (e.g. an R3F entry-file
@@ -394,6 +437,13 @@ export interface EditorState {
394
437
  * attach/detach report, and the command receipts). Absent against an older
395
438
  * server, and absent for a tab the server cannot measure.
396
439
  *
440
+ * `pageErrors` is the OTHER half of that verdict — WHY. Uncaught errors and
441
+ * unhandled rejections captured by the page shell's inline bootstrap, which
442
+ * runs before the module graph, so the boot failure that leaves no listener
443
+ * is exactly the one they explain. They come back on a plain GET and need no
444
+ * cooperation from the page beyond the handler itself. `[]` means the page
445
+ * reported none; absent means the server cannot measure this tab.
446
+ *
397
447
  * `census` is the tab's RESOURCE PROFILE, sampled by the page every five
398
448
  * seconds and carried on the heartbeat: what a browser-level renderer death
399
449
  * would otherwise leave unexplained. `heapUsedMB`/`heapLimitMB` are null off
@@ -408,12 +458,17 @@ export interface EditorState {
408
458
  presentFor: number;
409
459
  lastBeatAgo: number | null;
410
460
  epochCount: number;
461
+ /** Age of the DOCUMENT running in this tab (this page-load), as opposed
462
+ * to `presentFor`, which is the age of the tab and survives its
463
+ * reloads. Absent against an older server. */
464
+ epochAgeMs?: number;
411
465
  visibility: 'visible' | 'hidden';
412
466
  route: 'project' | 'no-project' | 'unknown';
413
467
  blessed: boolean;
414
468
  channel: 'open' | 'down';
415
469
  unresponsive: boolean;
416
470
  commandListener?: 'ready' | 'not attached' | (string & {});
471
+ pageErrors?: string[];
417
472
  census?: {
418
473
  heapUsedMB: number | null;
419
474
  heapLimitMB: number | null;
@@ -432,6 +487,33 @@ export interface EditorState {
432
487
  * stopped trying — the browser, not the editor, is what to check.
433
488
  */
434
489
  tabAutoOpen?: { attempts: number; stopped: boolean };
490
+ /**
491
+ * Epoch ms of the last genuine HTML page load this server served — i.e.
492
+ * when the editor tab last did a FULL document load (first open, reload,
493
+ * self-heal). Server-observed (`editor-server.ts`'s response-finish
494
+ * middleware), never browser-reported. `null` until the first page load.
495
+ *
496
+ * P20 reads this against {@link publicAssets} to answer "did bytes under
497
+ * `public/` change since the running document loaded", which is when
498
+ * module-scope loaders and the page-lifetime asset caches (Pixi `Assets`,
499
+ * three's loader caches) can still be serving the OLD bytes.
500
+ */
501
+ lastIndexRequestAt?: number | null;
502
+ /**
503
+ * P20 — what the server has OBSERVED land under this project's `public/`
504
+ * during this server lifetime, from the same chokidar watcher that
505
+ * broadcasts `assets-changed`. `lastChangedAt` is `null` when nothing has
506
+ * changed since the server started. Absent against an older server.
507
+ *
508
+ * This is a divergence signal, not a cache verdict: nothing here knows
509
+ * whether the running page actually holds a stale copy of those bytes,
510
+ * only that they changed after it loaded.
511
+ */
512
+ publicAssets?: {
513
+ lastChangedAt: number | null;
514
+ lastPath: string | null;
515
+ changedCount: number;
516
+ };
435
517
  /**
436
518
  * Epoch ms when the server last received a state POST from a browser tab —
437
519
  * i.e. the age of the cached snapshot above. Omitted if no tab has ever
@@ -515,13 +597,76 @@ export interface EditorState {
515
597
  * project has no readable manifest name.
516
598
  */
517
599
  projectName?: string | null;
600
+ /**
601
+ * The open project's ADAPTER, resolved (ARCHITECTURE-CORE §The editor
602
+ * protocol). `source: 'module'` means the project's own `vgai.adapter.ts`
603
+ * supplied the binding table; `'native'` means it declared none and got
604
+ * `nativeAdapter()` — the declared native default, not a silent fallback.
605
+ *
606
+ * `null`/absent means NOBODY HAS LOOKED YET (no project open, or the load
607
+ * has not finished), which is deliberately distinct from a loaded adapter
608
+ * whose `scenes.entries` is empty — that is a real, gradable answer. A
609
+ * non-null `error` means the project's own module did NOT load and the
610
+ * table below is the native default standing in, with the failure named.
611
+ *
612
+ * Structurally declared here rather than imported from
613
+ * `@vgai/engine/adapter/adapter-module` because this interface is the WIRE
614
+ * contract: everything in it has already been through JSON.
615
+ */
616
+ adapter?: {
617
+ source: 'module' | 'native';
618
+ modulePath: string | null;
619
+ regions: {
620
+ id: string;
621
+ surface: string;
622
+ /** true = this region grades the game's own dev layer, not shipped content. */
623
+ dev: boolean;
624
+ projector: string;
625
+ dialect: string | null;
626
+ anchors: string[];
627
+ }[];
628
+ scenes: {
629
+ default: string | null;
630
+ entries: {
631
+ id: string;
632
+ label: string;
633
+ kind: 'scene' | 'prefab';
634
+ region: string | null;
635
+ authorable: boolean;
636
+ reach: { kind: string; [field: string]: unknown };
637
+ source?: { path: string; export?: string };
638
+ finder?: string;
639
+ }[];
640
+ };
641
+ notes: string[];
642
+ error: string | null;
643
+ } | null;
518
644
  }
519
645
 
520
646
  export type ViewPreset = 'top' | 'front' | 'right' | 'perspective';
521
647
  export type ShadingMode = 'solid' | 'clay' | 'unlit' | 'wireframe' | 'normals' | 'overdraw';
648
+
649
+ /** One operation on the active native animation document's recorder. */
650
+ export type AnimationCaptureAction = 'start' | 'stop' | 'review' | 'commit' | 'discard';
522
651
  export type TransformMode = 'translate' | 'rotate' | 'scale';
523
652
  export type TransformSpace = 'world' | 'local';
524
653
 
654
+ /** Stable editor-owned workspace documents that may appear in a shareable
655
+ * view. One runtime list owns both URL parsing and the public id type. */
656
+ export const EDITOR_VIEW_WORKSPACE_DOCUMENT_IDS = [
657
+ 'workspace:scene',
658
+ 'workspace:game',
659
+ 'workspace:3d-components',
660
+ 'workspace:2d-components',
661
+ 'workspace:ui-components',
662
+ 'workspace:dev',
663
+ 'workspace:build-profiles',
664
+ 'account',
665
+ 'project-tools',
666
+ ] as const;
667
+
668
+ export type EditorViewWorkspaceDocumentId = (typeof EDITOR_VIEW_WORKSPACE_DOCUMENT_IDS)[number];
669
+
525
670
  /**
526
671
  * A durable, intentionally small projection of what an editor is presenting.
527
672
  * This is not workspace persistence: panel sizes, transient tool state, and
@@ -538,12 +683,7 @@ export type EditorViewDocument =
538
683
  | { kind: 'generation'; id: string }
539
684
  | {
540
685
  kind: 'workspace';
541
- id:
542
- | 'workspace:scene'
543
- | 'workspace:game'
544
- | 'workspace:build-profiles'
545
- | 'account'
546
- | 'project-tools';
686
+ id: EditorViewWorkspaceDocumentId;
547
687
  };
548
688
 
549
689
  export interface EditorView {
@@ -556,7 +696,7 @@ export interface EditorView {
556
696
  frame?: 'document' | 'selection';
557
697
  grid?: boolean;
558
698
  };
559
- utility?: 'profiler' | 'console' | 'animation' | 'light-explorer';
699
+ utility?: 'profiler' | 'console' | 'animation' | 'behavior' | 'light-explorer';
560
700
  }
561
701
 
562
702
  export interface PresentedEditorView {
@@ -727,6 +867,41 @@ export interface InspectedNothing {
727
867
  * Narrow with `'none' in result`. */
728
868
  export type InspectedInspection = InspectedSubject | InspectedNothing;
729
869
 
870
+ /**
871
+ * WHERE THIS WRITE WENT — carried by every `editor.setField()` ack.
872
+ *
873
+ * A write with no persistence route still succeeds: it lands on the live
874
+ * object and journals live-only, exactly as designed. Without this the ack was
875
+ * indistinguishable from one that reached a file, so a caller could only find
876
+ * out by diffing the tree — and a healthy consent-off session read as a silent
877
+ * no-op. `persisted: false` with `destination: "live-only (not saved)"` is the
878
+ * honest floor: never silence, and never a fabricated file name.
879
+ *
880
+ * IT IS PER-EDIT, produced by the component that performed the write and
881
+ * returned through the editor's persistence pipe — never a property of the
882
+ * session, the surface or the adapter. A composite holding a live-only three
883
+ * root beside a source-backed DOM root has no single true answer, and the
884
+ * adapter-wide one it used to give was the DOM root's (measured on the
885
+ * vendored racing game: a three-root edit acked `persisted: true` against a
886
+ * file it never touched). The ack is also AWAITED: it resolves after the bytes
887
+ * have landed, so a caller holding it can diff the tree immediately.
888
+ */
889
+ export interface InspectedWriteDestination {
890
+ /** Where THIS edit's bytes landed, in the writer's own words — a source
891
+ * file, the game's own JSX, or a named non-target like
892
+ * `"live-only (not saved)"`. */
893
+ destination: string;
894
+ /** Whether a byte actually moved for THIS edit. */
895
+ persisted: boolean;
896
+ }
897
+
898
+ /** What `editor.setField()` answers: the subject after the write, plus where
899
+ * the write went. */
900
+ export interface InspectedFieldWrite {
901
+ subject: InspectedInspection;
902
+ write: InspectedWriteDestination;
903
+ }
904
+
730
905
  // ---------------------------------------------------------------- hierarchy
731
906
  //
732
907
  // The wire mirror of the editor's own `SerializedHierarchyPanel`