@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.
- package/dist/.tsbuildinfo +1 -0
- package/dist/editor.d.ts +78 -55
- package/dist/editor.js +129 -76
- package/dist/game-client/bridge-heartbeat.d.ts +49 -0
- package/dist/game-client/bridge-heartbeat.js +46 -0
- package/dist/game-client/bridge-transport.d.ts +75 -0
- package/dist/game-client/bridge-transport.js +19 -0
- package/dist/game-client/client.d.ts +293 -0
- package/dist/game-client/client.js +706 -0
- package/dist/game-client/errors.d.ts +57 -0
- package/dist/game-client/errors.js +76 -0
- package/dist/game-client/events-matcher.d.ts +41 -0
- package/dist/game-client/events-matcher.js +68 -0
- package/dist/game-client/failure-block.d.ts +93 -0
- package/dist/game-client/failure-block.js +97 -0
- package/dist/game-client/fast-forward.d.ts +125 -0
- package/dist/game-client/fast-forward.js +122 -0
- package/dist/game-client/hidden-recovery.d.ts +85 -0
- package/dist/game-client/hidden-recovery.js +105 -0
- package/dist/game-client/index.d.ts +40 -0
- package/dist/game-client/index.js +26 -0
- package/dist/game-client/perf-sampling.d.ts +56 -0
- package/dist/game-client/perf-sampling.js +85 -0
- package/dist/game-client/relay-transport.d.ts +100 -0
- package/dist/game-client/relay-transport.js +237 -0
- package/dist/game-client/screenshot-target.d.ts +60 -0
- package/dist/game-client/screenshot-target.js +68 -0
- package/dist/game-client/state-cap.d.ts +7 -0
- package/dist/game-client/state-cap.js +21 -0
- package/dist/game-client/types.d.ts +128 -0
- package/dist/game-client/types.js +15 -0
- package/dist/game-client/wait-for.d.ts +155 -0
- package/dist/game-client/wait-for.js +229 -0
- package/dist/game.d.ts +47 -18
- package/dist/game.js +59 -16
- package/dist/index.d.ts +46 -21
- package/dist/index.js +51 -20
- package/dist/session.d.ts +4 -4
- package/dist/session.js +7 -7
- package/dist/tools.d.ts +12 -3
- package/dist/tools.js +15 -6
- package/package.json +10 -5
- package/src/editor.ts +142 -96
- package/src/game-client/bridge-heartbeat.ts +61 -0
- package/src/game-client/bridge-transport.ts +73 -0
- package/src/game-client/client.ts +836 -0
- package/src/game-client/errors.ts +96 -0
- package/src/game-client/events-matcher.ts +106 -0
- package/src/game-client/failure-block.ts +199 -0
- package/src/game-client/fast-forward.ts +175 -0
- package/src/game-client/hidden-recovery.ts +149 -0
- package/src/game-client/index.ts +98 -0
- package/src/game-client/perf-sampling.ts +94 -0
- package/src/game-client/relay-transport.ts +311 -0
- package/src/game-client/screenshot-target.ts +91 -0
- package/src/game-client/state-cap.ts +29 -0
- package/src/game-client/types.ts +137 -0
- package/src/game-client/wait-for.ts +327 -0
- package/src/game.ts +96 -16
- package/src/index.ts +68 -31
- package/src/session.ts +8 -10
- 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 }` (
|
|
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-
|
|
6
|
-
* `vgai show viewport
|
|
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
|
-
*
|
|
11
|
-
* exception
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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(
|
|
97
|
-
|
|
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
|
|
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
|
|
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
|
|
107
|
+
return this.#client.captureActiveDocument(size);
|
|
122
108
|
}
|
|
123
109
|
|
|
124
|
-
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
|
119
|
+
await this.#client.play(opts);
|
|
147
120
|
}
|
|
148
121
|
|
|
149
122
|
async stop(): Promise<void> {
|
|
150
|
-
await this
|
|
123
|
+
await this.#client.stop();
|
|
151
124
|
}
|
|
152
125
|
|
|
153
126
|
async pause(): Promise<void> {
|
|
154
|
-
await this
|
|
127
|
+
await this.#client.pause();
|
|
155
128
|
}
|
|
156
129
|
|
|
157
130
|
async resume(): Promise<void> {
|
|
158
|
-
await this
|
|
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
|
|
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
|
|
144
|
+
await this.#client.selectAll();
|
|
172
145
|
return;
|
|
173
146
|
}
|
|
174
|
-
await this
|
|
147
|
+
await this.#client.select(id);
|
|
175
148
|
}
|
|
176
149
|
|
|
177
150
|
/** Mirrors `vgai deselect`. */
|
|
178
151
|
async deselect(): Promise<void> {
|
|
179
|
-
await this
|
|
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
|
|
158
|
+
await this.#client.focusEntity(id);
|
|
186
159
|
return;
|
|
187
160
|
}
|
|
188
|
-
await this
|
|
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
|
|
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 <
|
|
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-
|
|
199
|
-
await this
|
|
197
|
+
case 'viewport-edit':
|
|
198
|
+
await this.#client.showViewport('edit');
|
|
200
199
|
return;
|
|
201
|
-
case 'viewport-
|
|
202
|
-
await this
|
|
200
|
+
case 'viewport-play':
|
|
201
|
+
await this.#client.showViewport('play');
|
|
203
202
|
return;
|
|
204
203
|
case 'inspector':
|
|
205
|
-
await this
|
|
204
|
+
await this.#client.showInspector();
|
|
206
205
|
return;
|
|
207
206
|
case 'console':
|
|
208
|
-
await this
|
|
207
|
+
await this.#client.toggleConsole();
|
|
209
208
|
return;
|
|
210
209
|
case 'build':
|
|
211
|
-
await this
|
|
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
|
|
217
|
+
await this.#client.openAsset(path, kind ?? inferAssetKind(path));
|
|
219
218
|
}
|
|
220
219
|
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
242
|
+
await this.#client.setGrid(on);
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
async helpers(on: boolean): Promise<void> {
|
|
231
|
-
await this
|
|
246
|
+
await this.#client.setHelpers(on);
|
|
232
247
|
}
|
|
233
248
|
|
|
234
249
|
async stats(on: boolean): Promise<void> {
|
|
235
|
-
await this
|
|
250
|
+
await this.#client.setStats(on);
|
|
236
251
|
}
|
|
237
252
|
|
|
238
253
|
async shading(mode: ShadingMode): Promise<void> {
|
|
239
|
-
await this
|
|
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
|
|
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
|
|
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
|
+
}
|