@vgai/live 0.5.2 → 0.5.4

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 (62) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/editor.d.ts +78 -55
  3. package/dist/editor.js +129 -76
  4. package/dist/game-client/bridge-heartbeat.d.ts +49 -0
  5. package/dist/game-client/bridge-heartbeat.js +46 -0
  6. package/dist/game-client/bridge-transport.d.ts +75 -0
  7. package/dist/game-client/bridge-transport.js +19 -0
  8. package/dist/game-client/client.d.ts +293 -0
  9. package/dist/game-client/client.js +706 -0
  10. package/dist/game-client/errors.d.ts +57 -0
  11. package/dist/game-client/errors.js +76 -0
  12. package/dist/game-client/events-matcher.d.ts +41 -0
  13. package/dist/game-client/events-matcher.js +68 -0
  14. package/dist/game-client/failure-block.d.ts +93 -0
  15. package/dist/game-client/failure-block.js +97 -0
  16. package/dist/game-client/fast-forward.d.ts +125 -0
  17. package/dist/game-client/fast-forward.js +122 -0
  18. package/dist/game-client/hidden-recovery.d.ts +85 -0
  19. package/dist/game-client/hidden-recovery.js +105 -0
  20. package/dist/game-client/index.d.ts +40 -0
  21. package/dist/game-client/index.js +26 -0
  22. package/dist/game-client/perf-sampling.d.ts +56 -0
  23. package/dist/game-client/perf-sampling.js +85 -0
  24. package/dist/game-client/relay-transport.d.ts +100 -0
  25. package/dist/game-client/relay-transport.js +237 -0
  26. package/dist/game-client/screenshot-target.d.ts +60 -0
  27. package/dist/game-client/screenshot-target.js +68 -0
  28. package/dist/game-client/state-cap.d.ts +7 -0
  29. package/dist/game-client/state-cap.js +21 -0
  30. package/dist/game-client/types.d.ts +128 -0
  31. package/dist/game-client/types.js +15 -0
  32. package/dist/game-client/wait-for.d.ts +155 -0
  33. package/dist/game-client/wait-for.js +229 -0
  34. package/dist/game.d.ts +47 -18
  35. package/dist/game.js +59 -16
  36. package/dist/index.d.ts +46 -21
  37. package/dist/index.js +51 -20
  38. package/dist/session.d.ts +4 -4
  39. package/dist/session.js +7 -7
  40. package/dist/tools.d.ts +12 -3
  41. package/dist/tools.js +15 -6
  42. package/package.json +10 -5
  43. package/src/editor.ts +142 -96
  44. package/src/game-client/bridge-heartbeat.ts +61 -0
  45. package/src/game-client/bridge-transport.ts +73 -0
  46. package/src/game-client/client.ts +836 -0
  47. package/src/game-client/errors.ts +96 -0
  48. package/src/game-client/events-matcher.ts +106 -0
  49. package/src/game-client/failure-block.ts +199 -0
  50. package/src/game-client/fast-forward.ts +175 -0
  51. package/src/game-client/hidden-recovery.ts +149 -0
  52. package/src/game-client/index.ts +98 -0
  53. package/src/game-client/perf-sampling.ts +94 -0
  54. package/src/game-client/relay-transport.ts +311 -0
  55. package/src/game-client/screenshot-target.ts +91 -0
  56. package/src/game-client/state-cap.ts +29 -0
  57. package/src/game-client/types.ts +137 -0
  58. package/src/game-client/wait-for.ts +327 -0
  59. package/src/game.ts +96 -16
  60. package/src/index.ts +68 -31
  61. package/src/session.ts +8 -10
  62. package/src/tools.ts +19 -6
package/src/editor.ts CHANGED
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * `LiveEditor` — the editor-control half of `@vgai/live`'s `{ editor, game,
3
- * page }` (SHARED-SESSION-SPEC.md, Wave 2). Methods are named after the
3
+ * page }` (Wave 2). Methods are named after the
4
4
  * ACTION, not the CLI flag spelling (e.g. `select('all')` rather than a
5
- * separate `selectAll`, `showPanel('viewport-game')` rather than
6
- * `vgai show viewport game`'s two-token shape) — see each method's own doc
5
+ * separate `selectAll`, `showPanel('viewport-play')` rather than
6
+ * `vgai show viewport play`'s two-token shape) — see each method's own doc
7
7
  * comment for the exact CLI verb / `EditorClient` call it mirrors.
8
8
  *
9
- * Every method reuses `@vgai/editor-sdk`'s `EditorClient` — this module
10
- * never hand-rolls a `fetch` to `/__editor/command` itself. The ONE
11
- * exception is `applyDiff`, which is FILE mode (writes the scene file
12
- * directly), not a live wire command — see its own doc comment.
9
+ * Every method reuses `@vgai/editor-sdk`'s `EditorClient` — this module never
10
+ * hand-rolls a `fetch` to `/__editor/command` itself. (WO-8 removed the one
11
+ * exception, `applyDiff`, which was FILE mode rather than a live wire command.)
13
12
  */
14
13
 
15
14
  import type {
@@ -17,39 +16,19 @@ import type {
17
16
  AssetKind,
18
17
  AssetPreviewCapture,
19
18
  AssetPreviewOptions,
19
+ AssetPreviewSource,
20
20
  EditorClient,
21
21
  EditorState,
22
22
  EditorView,
23
+ InspectedInspection,
23
24
  PresentedEditorView,
24
25
  ShadingMode,
25
26
  ViewPreset,
26
27
  ViewportCapture,
27
28
  } from '@vgai/editor-sdk';
28
- import { operations } from '@vgai/sdk';
29
29
 
30
30
  /** `vgai show <...>`'s four sub-verbs folded into one action name — see `showPanel`. */
31
- export type PanelName = 'viewport-scene' | 'viewport-game' | 'inspector' | 'console' | 'build';
32
-
33
- /** Mirrors `@vgai/sdk`'s `project.scene.apply` operation input — the same shape `vgai apply-diff` builds from its `<scene> <patch>` file arguments. `patch` is a `SceneDiff` JSON object (validated by the operation's own Zod schema — a malformed patch throws, never silently no-ops). */
34
- export interface ApplyDiffInput {
35
- /** Project-relative path to the `.vscn.json` scene file to modify. */
36
- scenePath: string;
37
- /** A `SceneDiff`-shaped patch object. */
38
- patch: unknown;
39
- /** Validate + report only — never write to disk. Default `false`. */
40
- dryRun?: boolean;
41
- /** Refuse a stale write when the file's content hash no longer matches (optimistic concurrency) — omit to skip the check. */
42
- baseHash?: string;
43
- }
44
-
45
- /** The `project.scene.apply` operation's result shape. */
46
- export interface ApplyDiffResult {
47
- dryRun: boolean;
48
- written: boolean;
49
- filesChanged: string[];
50
- before: { scene: unknown };
51
- after: { scene: unknown };
52
- }
31
+ export type PanelName = 'viewport-edit' | 'viewport-play' | 'inspector' | 'console' | 'build';
53
32
 
54
33
  const EXTENSION_KIND: Record<string, AssetKind> = {
55
34
  '.glb': 'model',
@@ -59,6 +38,7 @@ const EXTENSION_KIND: Record<string, AssetKind> = {
59
38
  '.jpeg': 'image',
60
39
  '.webp': 'image',
61
40
  '.gif': 'image',
41
+ '.svg': 'image',
62
42
  '.mp3': 'audio',
63
43
  '.ogg': 'audio',
64
44
  '.wav': 'audio',
@@ -73,7 +53,10 @@ const EXTENSION_KIND: Record<string, AssetKind> = {
73
53
  * on. Callers with an unusual extension can always pass `kind` explicitly.
74
54
  */
75
55
  export function inferAssetKind(path: string): AssetKind {
76
- if (path.endsWith('.vscn.json')) return 'scene';
56
+ // WO-8: `.vscn.json` mapped to 'scene' here. That format is deleted, and
57
+ // `.prefab.json` is likewise retired (PRs #578/#581/#589) but the `'prefab'`
58
+ // AssetKind itself still exists in `@vgai/editor-sdk`, so the mapping is left
59
+ // for that retirement's own follow-up rather than half-removed here.
77
60
  if (path.endsWith('.prefab.json')) return 'prefab';
78
61
  if (path.endsWith('.mat.json')) return 'material';
79
62
  const dot = path.lastIndexOf('.');
@@ -82,23 +65,26 @@ export function inferAssetKind(path: string): AssetKind {
82
65
  }
83
66
 
84
67
  export class LiveEditor {
85
- constructor(
86
- private readonly client: EditorClient,
87
- /** Absolute project root this session is bound to — used only by `applyDiff` (FILE mode; every other method goes over the wire and never needs it). */
88
- private readonly projectRoot: string,
89
- ) {}
68
+ /** `#`-private, not `private`: `vgai eval --list` enumerates this object's
69
+ * real runtime members, and TypeScript's erased `private` would leave the
70
+ * raw `EditorClient` advertised beside them. See `./game-client/`'s
71
+ * `client.ts` (GameClient's field block) for the full reasoning. */
72
+ readonly #client: EditorClient;
73
+
74
+ constructor(client: EditorClient) {
75
+ this.#client = client;
76
+ }
90
77
 
91
78
  /**
92
- * No `path` -> the currently open scene's save path (`status().savePath`).
93
- * `path` given -> opens it (`EditorClient.openScene` — the same wire call
94
- * `vgai scene <path>` sends), and the given path is returned back.
79
+ * The active authoring adapter's persistence destination where a save would
80
+ * land (`status().savePath`).
81
+ *
82
+ * WO-8: this also took a `path` and OPENED that scene through
83
+ * `EditorClient.openScene` -> the `open-scene` relay verb. That verb now rejects
84
+ * (the `.vscn.json` format is deleted), so only the read remains.
95
85
  */
96
- async scene(path?: string): Promise<string | null> {
97
- if (path !== undefined) {
98
- await this.client.openScene(path);
99
- return path;
100
- }
101
- const state = await this.client.getState();
86
+ async scene(): Promise<string | null> {
87
+ const state = await this.#client.getState();
102
88
  return state.savePath;
103
89
  }
104
90
 
@@ -108,144 +94,204 @@ export class LiveEditor {
108
94
  * workspace or document payload.
109
95
  */
110
96
  async present(view: EditorView): Promise<PresentedEditorView> {
111
- return this.client.present(view);
97
+ return this.#client.present(view);
112
98
  }
113
99
 
114
100
  /** The human editor's actual active document, selection, camera and utility. */
115
101
  async currentView(): Promise<EditorView> {
116
- return this.client.currentView();
102
+ return this.#client.currentView();
117
103
  }
118
104
 
119
105
  /** Capture the same center document the human is currently looking at. */
120
106
  async captureActiveDocument(size?: number): Promise<ActiveDocumentCapture> {
121
- return this.client.captureActiveDocument(size);
107
+ return this.#client.captureActiveDocument(size);
122
108
  }
123
109
 
124
- /**
125
- * FILE mode writes the scene file directly. NOT a live wire command:
126
- * there is no live-editor wire op for this yet (`vgai apply-diff`'s own
127
- * module doc names the identical gap: "LIVE MODE NOT BUILT ... route the
128
- * patch through a RUNNING editor"). Reuses the exact same engine
129
- * machinery `vgai apply-diff` uses, through `@vgai/sdk`'s
130
- * `project.scene.apply` operation (the operation-registry's own
131
- * projection of that logic) — never hand-rolled here.
110
+ /*
111
+ * `applyDiff` lived here. It dispatched `project.scene.apply`, one of the
112
+ * twelve `.vscn` document tools deleted with the format, so there is nothing
113
+ * left for it to call. `ApplyDiffInput` / `ApplyDiffResult` went with it, and
114
+ * so did `projectRoot` this was the ONE method that used it (every other
115
+ * method goes over the wire).
132
116
  */
133
- async applyDiff(input: ApplyDiffInput): Promise<ApplyDiffResult> {
134
- const outcome = await operations.dispatch('project.scene.apply', input, {
135
- projectRoot: this.projectRoot,
136
- });
137
- if (!outcome.ok) {
138
- throw new Error(
139
- `@vgai/live: applyDiff failed [${outcome.error.code}]: ${outcome.error.message}`,
140
- );
141
- }
142
- return outcome.data as ApplyDiffResult;
143
- }
144
117
 
145
118
  async play(opts?: { seed?: number }): Promise<void> {
146
- await this.client.play(opts);
119
+ await this.#client.play(opts);
147
120
  }
148
121
 
149
122
  async stop(): Promise<void> {
150
- await this.client.stop();
123
+ await this.#client.stop();
151
124
  }
152
125
 
153
126
  async pause(): Promise<void> {
154
- await this.client.pause();
127
+ await this.#client.pause();
155
128
  }
156
129
 
157
130
  async resume(): Promise<void> {
158
- await this.client.resume();
131
+ await this.#client.resume();
159
132
  }
160
133
 
161
134
  /** Advance `n` frames (default 1) — `EditorClient.step()` sent `n` times, mirroring `vgai step` run repeatedly. */
162
135
  async step(n = 1): Promise<void> {
163
136
  for (let i = 0; i < n; i++) {
164
- await this.client.step();
137
+ await this.#client.step();
165
138
  }
166
139
  }
167
140
 
168
141
  /** `'all'` -> `EditorClient.selectAll()` (mirrors `vgai select --all`); otherwise `EditorClient.select(id)` (mirrors `vgai select <entityId>`). */
169
142
  async select(id: string | 'all'): Promise<void> {
170
143
  if (id === 'all') {
171
- await this.client.selectAll();
144
+ await this.#client.selectAll();
172
145
  return;
173
146
  }
174
- await this.client.select(id);
147
+ await this.#client.select(id);
175
148
  }
176
149
 
177
150
  /** Mirrors `vgai deselect`. */
178
151
  async deselect(): Promise<void> {
179
- await this.client.select(null);
152
+ await this.#client.select(null);
180
153
  }
181
154
 
182
155
  /** No `id` -> focus the current selection (mirrors bare `vgai focus`); `id` given -> focus that entity. */
183
156
  async focus(id?: string): Promise<void> {
184
157
  if (id !== undefined) {
185
- await this.client.focusEntity(id);
158
+ await this.#client.focusEntity(id);
186
159
  return;
187
160
  }
188
- await this.client.focusSelection();
161
+ await this.#client.focusSelection();
162
+ }
163
+
164
+ /**
165
+ * Frame the edit viewport camera on one entity. Same framing as
166
+ * `focus(id)`, but an entity id the scene does not know THROWS, naming the
167
+ * id — where `focus` quietly does nothing. Reach for this whenever the next
168
+ * step reads the viewport (`screenshot()`, an
169
+ * `assetPreview(..., { stage: 'scene' })`): a framing that silently missed
170
+ * would otherwise be indistinguishable from one that worked.
171
+ */
172
+ async frame(entityId: string): Promise<void> {
173
+ await this.#client.frameEntity(entityId);
189
174
  }
190
175
 
191
176
  async view(preset: ViewPreset): Promise<void> {
192
- await this.client.viewPreset(preset);
177
+ await this.#client.viewPreset(preset);
178
+ }
179
+
180
+ /**
181
+ * Show several instances of the running game split-screen — the whole point
182
+ * of multiplayer authoring: `await editor.instances(2)` puts a second view
183
+ * beside the first, watchable by a human, each drivable by its own bot
184
+ * (`game.instance(id)` / `tools.run(name, params, { instance })`). Pass a
185
+ * TOTAL count (default "Player 1"/"Player 2"/… labels) or explicit names
186
+ * (`editor.instances(['Alice', 'Bob'])`, index 0 = primary). A count of `1`
187
+ * (or a single name) collapses back to a single view. Requires a live play
188
+ * session.
189
+ */
190
+ async instances(countOrNames: number | string[]): Promise<void> {
191
+ await this.#client.setInstanceCount(countOrNames);
193
192
  }
194
193
 
195
- /** `vgai show <viewport <scene|game>|inspector|console|build>`'s four sub-verbs, folded into one action name. */
194
+ /** `vgai show <viewport <edit|play>|inspector|console|build>`'s four sub-verbs, folded into one action name. */
196
195
  async showPanel(name: PanelName): Promise<void> {
197
196
  switch (name) {
198
- case 'viewport-scene':
199
- await this.client.showViewport('scene');
197
+ case 'viewport-edit':
198
+ await this.#client.showViewport('edit');
200
199
  return;
201
- case 'viewport-game':
202
- await this.client.showViewport('game');
200
+ case 'viewport-play':
201
+ await this.#client.showViewport('play');
203
202
  return;
204
203
  case 'inspector':
205
- await this.client.showInspector();
204
+ await this.#client.showInspector();
206
205
  return;
207
206
  case 'console':
208
- await this.client.toggleConsole();
207
+ await this.#client.toggleConsole();
209
208
  return;
210
209
  case 'build':
211
- await this.client.showBuild();
210
+ await this.#client.showBuild();
212
211
  return;
213
212
  }
214
213
  }
215
214
 
216
215
  /** `kind` inferred from `path`'s extension when omitted (`inferAssetKind`) — pass it explicitly to override. */
217
216
  async openAsset(path: string, kind?: AssetKind): Promise<void> {
218
- await this.client.openAsset(path, kind ?? inferAssetKind(path));
217
+ await this.#client.openAsset(path, kind ?? inferAssetKind(path));
219
218
  }
220
219
 
221
- /** Captures the editor's native Asset Lab four-view preview for `path` (a project-relative asset path). */
222
- async assetPreview(path: string, options?: AssetPreviewOptions): Promise<AssetPreviewCapture> {
223
- return this.client.captureAssetPreview({ assetPath: path }, options);
220
+ /**
221
+ * Captures the editor's native four-view preview. A bare string is a
222
+ * project-relative asset path (the common case); an explicit source object
223
+ * targets a path, a LIVE SCENE ENTITY (`assetPreview({ entityId }, …)`) —
224
+ * which is what makes `options.stage: 'scene'`, the entity photographed
225
+ * where it stands under the scene's own lighting, reachable from here — or
226
+ * RAW GLB BYTES (`assetPreview({ glbBase64 }, …)`), for a model that exists
227
+ * only in the calling Node process's memory and has never been written to
228
+ * disk. `stage` defaults to `'lab'`, the neutral Asset Lab staging this has
229
+ * always produced, and the bytes form is lab-only.
230
+ */
231
+ async assetPreview(
232
+ source: string | AssetPreviewSource,
233
+ options?: AssetPreviewOptions,
234
+ ): Promise<AssetPreviewCapture> {
235
+ return this.#client.captureAssetPreview(
236
+ typeof source === 'string' ? { assetPath: source } : source,
237
+ options,
238
+ );
224
239
  }
225
240
 
226
241
  async grid(on: boolean): Promise<void> {
227
- await this.client.setGrid(on);
242
+ await this.#client.setGrid(on);
228
243
  }
229
244
 
230
245
  async helpers(on: boolean): Promise<void> {
231
- await this.client.setHelpers(on);
246
+ await this.#client.setHelpers(on);
232
247
  }
233
248
 
234
249
  async stats(on: boolean): Promise<void> {
235
- await this.client.setStats(on);
250
+ await this.#client.setStats(on);
236
251
  }
237
252
 
238
253
  async shading(mode: ShadingMode): Promise<void> {
239
- await this.client.setShadingMode(mode);
254
+ await this.#client.setShadingMode(mode);
255
+ }
256
+
257
+ /**
258
+ * READ the inspector, as data — the serialized inspection subject
259
+ * (`editor.inspect()`; design: `docs/ARCHITECTURE-CORE.md` §Editor chrome,
260
+ * "The Inspection Model"). This is the Figma-Inspect analog: whatever a
261
+ * human would see in the inspector right now — the subject's identity, its
262
+ * verbs, and every identified section in display order, with a `fields`
263
+ * section's CURRENT VALUES at their scriptable `path`s.
264
+ *
265
+ * Reach for it whenever the next step depends on what an object actually
266
+ * IS: `await editor.select(id)` then `await editor.inspect()` answers "what
267
+ * properties does this thing have, and what are they set to" in one call,
268
+ * against the same model the panel renders — no scene-graph reads, no
269
+ * guessing at property names.
270
+ *
271
+ * When the inspector is showing NOTHING — nothing selected on a surface
272
+ * with no empty-state subject of its own, which is most of them — the answer
273
+ * is `{none: true}`, so "the human sees no inspector" and "the read failed"
274
+ * are never the same value. A surface whose empty space IS a real thing (an
275
+ * open Asset Lab document) still answers with that subject, and never with
276
+ * another surface's.
277
+ *
278
+ * A `custom` section body is a named opaque: the editor renders it with
279
+ * React, so the wire reports its identity rather than pretending to describe
280
+ * its rendering — plus, when the section can say what it DISPLAYS, a `data`
281
+ * payload in its own vocabulary (`transform` carries
282
+ * `{position, rotation, scale}`, rotation in Euler XYZ degrees).
283
+ */
284
+ async inspect(): Promise<InspectedInspection> {
285
+ return this.#client.inspect();
240
286
  }
241
287
 
242
288
  /** Mirrors `vgai status` — the full live editor state as JSON. */
243
289
  async status(): Promise<EditorState> {
244
- return this.client.getState();
290
+ return this.#client.getState();
245
291
  }
246
292
 
247
293
  /** A live viewport PNG (`EditorClient.captureViewport`) — no direct CLI verb exists; this is the closest wire read. */
248
294
  async screenshot(size?: number): Promise<ViewportCapture> {
249
- return this.client.captureViewport(size);
295
+ return this.#client.captureViewport(size);
250
296
  }
251
297
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * run-3 dogfood friction #2 — a legitimately-progressing script built from
3
+ * many short `game.input.hold()` calls looked WEDGED to anything watching its
4
+ * stdout for liveness, even though it was making real progress. Root cause: `GameInput.hold` (client.ts) collapsed
5
+ * what used to be a `waitSimTime` POLL loop into a single bridge call per
6
+ * hold (see its own doc comment — deliberately, to cut transport round
7
+ * trips), so a spec built from many holds and little else can go silent on
8
+ * stdout between them for minutes at a time.
9
+ *
10
+ * `wait-for.ts`'s `maybeHeartbeat` already solves the analogous problem for
11
+ * the `waitFor`/`waitSimTime` POLL loop, but it is deliberately SIM-TICK
12
+ * aware (a frozen sim clock must stay heartbeat-silent so that loop's own
13
+ * ~30s stall guard, `WAIT_FOR_STALL_POLL_LIMIT`, can still diagnose a
14
+ * genuinely-stuck game rather than a heartbeat papering over it). This is
15
+ * the companion for `GameClient`'s generic bridge dispatch
16
+ * (`callBridge`/`callBridgeAsync`), which has no poll loop and no sim tick
17
+ * to check at all: "wire activity" here just means a bridge call was
18
+ * actually DISPATCHED — sequential awaits mean a new dispatch can only
19
+ * happen once the previous one resolved, so a genuinely wedged page (bridge
20
+ * calls that never resolve at all) still goes — and stays — silent. The
21
+ * throttle exists only to bound
22
+ * stdout volume during a burst of fast calls, not to filter out "fake"
23
+ * activity.
24
+ */
25
+
26
+ export const BRIDGE_HEARTBEAT_INTERVAL_MS = 30_000;
27
+
28
+ /** Mutable-by-replacement bookkeeping a caller threads through successive
29
+ * `maybeBridgeHeartbeat` calls — mirrors `wait-for.ts`'s `HeartbeatState`
30
+ * shape/style, minus the tick field (no sim-progress condition here). */
31
+ export interface BridgeHeartbeatState {
32
+ lastEmitWallMs: number;
33
+ }
34
+
35
+ /** The liveness line this prints to stdout — greppable, and prefixed
36
+ * distinctly from `wait-for.ts`'s `vgai-heartbeat` so the two liveness
37
+ * sources are distinguishable in a run's log. */
38
+ export function formatBridgeHeartbeatLine(testTitle: string, method: string): string {
39
+ return `vgai-bridge-heartbeat ${testTitle} method=${method}`;
40
+ }
41
+
42
+ /**
43
+ * Pure decision, called once per bridge dispatch: should a heartbeat print
44
+ * now, and what's the updated bookkeeping? Returns the SAME `state` object
45
+ * (referentially) when nothing should emit, so a caller can cheaply no-op.
46
+ */
47
+ export function maybeBridgeHeartbeat(opts: {
48
+ nowMs: number;
49
+ method: string;
50
+ testTitle: string;
51
+ state: BridgeHeartbeatState;
52
+ }): { line: string | null; state: BridgeHeartbeatState } {
53
+ const wallElapsed = opts.nowMs - opts.state.lastEmitWallMs;
54
+ if (wallElapsed >= BRIDGE_HEARTBEAT_INTERVAL_MS) {
55
+ return {
56
+ line: formatBridgeHeartbeatLine(opts.testTitle, opts.method),
57
+ state: { lastEmitWallMs: opts.nowMs },
58
+ };
59
+ }
60
+ return { line: null, state: opts.state };
61
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The transport seam `GameClient` (`client.ts`) drives (#140). Two
3
+ * implementations answer the identical contract below:
4
+ * - `PageTransport` (`client.ts`) — Playwright's `page.evaluate` against
5
+ * `window.__vgai`, for a standalone game page the caller drives itself.
6
+ * - `RelayTransport` (`relay-transport.ts`) — the editor dev-server's
7
+ * session wire (`POST /__editor/command`, `bridge-call`/
8
+ * `bridge-screenshot` ops), driving the SAME live session a human already
9
+ * has open, with no new browser/window/vite instance.
10
+ *
11
+ * `wait-for.ts`/`events-matcher.ts`/`failure-block.ts`, and every method
12
+ * body on `GameClient` itself, are written against this interface only —
13
+ * none of them may know or care which transport is underneath (load-bearing
14
+ * for a planned default flip to relay-when-a-live-session-exists, and for a
15
+ * future one-shot REPL client reusing the same relay op). Deliberately NO
16
+ * `@playwright/test` import here, nor in `relay-transport.ts` — only
17
+ * `client.ts` is allowed to touch a live `Page` (see its own module doc).
18
+ */
19
+
20
+ /** Result of one generic bridge-method call — thrown page/relay-side errors
21
+ * never cross either transport boundary AS themselves (Node only keeps
22
+ * `.message` across `page.evaluate`; HTTP/JSON strips everything but what
23
+ * the server explicitly serializes), so both transports catch and report
24
+ * `code`/`data` explicitly, and `GameClient.unwrap` reconstructs a
25
+ * `SessionError` from that. */
26
+ export interface BridgeCallOutcome {
27
+ ok: boolean;
28
+ result?: unknown;
29
+ error?: { code: string | undefined; message: string; data?: unknown };
30
+ }
31
+
32
+ export interface BridgeTransport {
33
+ /** Sync-style bridge call (state/stateAll/providers/commands/events/
34
+ * snapshot/runTicks/input.*) — "sync-style" describes the PAGE side's own
35
+ * call, not this method, which is always async across either wire. */
36
+ call(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome>;
37
+ /** Async bridge call — `invoke` (debug commands may themselves be async). */
38
+ callAsync(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome>;
39
+ /** Whether the surface showing the game is currently hidden from the user
40
+ * (`document.hidden` on the page transport) — feeds `HiddenRecoveryDriver`. */
41
+ isHidden(): Promise<boolean>;
42
+ /** Bring the surface showing the game to the foreground. */
43
+ bringToFront(): Promise<void>;
44
+ /** Capture a screenshot to `path` (PNG). A transport that cannot support
45
+ * this should reject with a descriptive error rather than write a blank/
46
+ * corrupt file. */
47
+ screenshot(path: string): Promise<void>;
48
+ /**
49
+ * Wave-2 "one dialect, full capability" — runs a UI-automation step
50
+ * written as a literal `async (page) => {...}` (`GameClient.page()`,
51
+ * `client.ts`). `src` is `step.toString()`; `step` is the ORIGINAL
52
+ * function, wrapped so its own parameter type is erased to `unknown` (only
53
+ * `client.ts` — the one file allowed to touch a real `Page` — ever names
54
+ * the `Page` type itself).
55
+ *
56
+ * The two implementations differ ON PURPOSE, and that difference is the
57
+ * load-bearing honesty boundary this method exists to name:
58
+ * - `PageTransport` (`client.ts`) calls `step` DIRECTLY against the real
59
+ * Playwright `Page` — no serialization, so closures over outer Node
60
+ * values work here exactly like any ordinary `page.evaluate` callback.
61
+ * - `RelayTransport` (`relay-transport.ts`) ships `src` over the wire and
62
+ * reconstructs it with `new Function` INSIDE the editor page, against
63
+ * an in-page shim (`packages/editor/src/playwright-shim.ts`) — closure
64
+ * capture over anything outside the step's own body does NOT survive
65
+ * that trip (the same limitation class as Playwright's own `evaluate`
66
+ * serialization).
67
+ *
68
+ * Because the two transports differ this way, specs meant to pass
69
+ * unmodified under both hosts must be written as though ALWAYS
70
+ * serialized — inline every value the step needs.
71
+ */
72
+ runPageScript(src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
73
+ }