@vgai/sdk 0.4.0-canary.20260715.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +27 -0
  2. package/src/cinematic/capabilities-operations.ts +128 -0
  3. package/src/cinematic/cue-operations.ts +198 -0
  4. package/src/cinematic/gsap-operations.ts +126 -0
  5. package/src/cinematic/index.ts +59 -0
  6. package/src/cinematic/preview-operations.ts +279 -0
  7. package/src/cinematic/preview-transport.ts +244 -0
  8. package/src/cinematic/render-operations.ts +409 -0
  9. package/src/cinematic/render-transport.ts +238 -0
  10. package/src/cinematic/theatre-operations.ts +306 -0
  11. package/src/editor/camera-operations.ts +169 -0
  12. package/src/editor/console-operations.ts +87 -0
  13. package/src/editor/hierarchy-operations.ts +95 -0
  14. package/src/editor/index.ts +62 -0
  15. package/src/editor/open-operations.ts +209 -0
  16. package/src/editor/screenshot-operations.ts +99 -0
  17. package/src/editor/selection-operations.ts +144 -0
  18. package/src/editor/session-operations.ts +73 -0
  19. package/src/editor/source-location-operations.ts +106 -0
  20. package/src/editor/transport.ts +647 -0
  21. package/src/errors.ts +72 -0
  22. package/src/http/http-projection.ts +349 -0
  23. package/src/http/index.ts +11 -0
  24. package/src/index.ts +67 -0
  25. package/src/mcp/index.ts +16 -0
  26. package/src/mcp/mcp-projection.ts +288 -0
  27. package/src/operations.ts +83 -0
  28. package/src/play/control-operations.ts +205 -0
  29. package/src/play/debug-command-operations.ts +245 -0
  30. package/src/play/index.ts +66 -0
  31. package/src/play/input-operations.ts +316 -0
  32. package/src/play/lifecycle-operations.ts +271 -0
  33. package/src/play/log-operations.ts +279 -0
  34. package/src/play/run-ticks-operations.ts +141 -0
  35. package/src/play/state-operations.ts +210 -0
  36. package/src/play/status-operations.ts +160 -0
  37. package/src/play/transport.ts +728 -0
  38. package/src/project/asset-operations.ts +243 -0
  39. package/src/project/component-operations.ts +337 -0
  40. package/src/project/discovery-operations.ts +269 -0
  41. package/src/project/entity-operations.ts +366 -0
  42. package/src/project/index.ts +55 -0
  43. package/src/project/input-map-operations.ts +233 -0
  44. package/src/project/manifest-operations.ts +355 -0
  45. package/src/project/scene-operations.ts +426 -0
  46. package/src/project/shared.ts +299 -0
  47. package/src/registry.ts +285 -0
  48. package/src/render/capabilities/ffmpeg.ts +141 -0
  49. package/src/render/index.ts +15 -0
  50. package/src/render/render-cinematic.ts +1847 -0
  51. package/src/types.ts +101 -0
@@ -0,0 +1,728 @@
1
+ /**
2
+ * Transport seam for B4's `play.*` operations
3
+ * (docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §8 B4).
4
+ *
5
+ * `play.*` targets or launches a playable runtime (§5.7). TODAY, the only
6
+ * playable runtime this repo can launch/target is the one already mounted
7
+ * inside a connected editor's browser tab — `enterPlayMode`/`exitPlayMode`/
8
+ * `pausePlayMode`/`resumePlayMode`/`stepPlayMode`
9
+ * (`packages/editor/src/play-mode.ts`) driven through the SAME
10
+ * `POST /__editor/command` SSE relay B3's `editor.*` ops use
11
+ * (`packages/editor/server/editor-server.ts`). There is no separate
12
+ * "runtime-page" launcher for play sessions yet (that concept exists only
13
+ * for the deterministic render-control harness, `render-control.ts`'s
14
+ * `?vgai-render=1` page and `cinematic.render`'s own launched browser — a
15
+ * completely different, request-scoped, non-interactive harness). Every
16
+ * `play.*` op below is declared `host: 'runtime-page'` per §8 B1's taxonomy
17
+ * (these ops target/launch a playable runtime, which is the host
18
+ * classification's whole point), while the REAL transport underneath rides
19
+ * the existing editor-browser relay — see each op file's jsdoc for exactly
20
+ * which relay call it reuses, and PLAY-GAPS.md-style notes below for what
21
+ * has no relay wiring today.
22
+ *
23
+ * SESSION RESOLUTION: `resolvePlaySession` mirrors
24
+ * `../editor/transport.ts`'s `resolveEditorSession` byte-for-byte in
25
+ * PRECEDENCE (explicit `ctx.editorUrl` > `ctx.projectRoot` match > lowest
26
+ * live port) — deliberately NOT imported and reused directly, because
27
+ * `resolveEditorSession`'s parameter type is the full `EditorTransport`
28
+ * interface (selection/hierarchy/camera/screenshot/etc.), and forcing every
29
+ * `play.*` test's mock transport to also stub all of THOSE unrelated methods
30
+ * just to get session resolution would be pure boilerplate. This is the
31
+ * same "deliberate light duplicate" tradeoff `../editor/transport.ts`'s own
32
+ * module jsdoc documents for its session-registry reader — a few lines
33
+ * duplicated beats a much larger, unrelated interface coupling.
34
+ *
35
+ * REAL TRANSPORT SURFACE — what `HttpPlayTransport` actually talks to.
36
+ * Wave 5 (docs/SYNTHETIC-PLAYER-SPEC.md §3.5) closed the version-skew lie:
37
+ * `command-listener.ts`'s `handleCommand` now has a `default:` case answering
38
+ * `{ok:false, data:{code:'UNKNOWN_COMMAND_TYPE'}}` for any command type the
39
+ * connected editor page predates, and the relay's result leg carries `data`
40
+ * end to end (`reportCommandResult` → `/__editor/command-result` →
41
+ * `commandResponseFor` → `relayCommand` below). Methods here translate that
42
+ * structured marker into `undefined` — the undefined-means-unsupported
43
+ * convention `editor/transport.ts`'s `openStory`/`getCamera` established —
44
+ * so each op throws its own declared `*_UNSUPPORTED` code against a stale
45
+ * editor, never trusting a fabricated ack.
46
+ *
47
+ * - start/pause/resume/stop/frameStep: `POST /__editor/command` with
48
+ * `{type:'play'|'stop'|'pause'|'resume'|'step'}` — ALL FIVE are real,
49
+ * already-handled cases in `command-listener.ts`'s `handleCommand`
50
+ * (`case 'play'` awaits the browser's `enterPlayMode()`, i.e. the whole
51
+ * async game boot, before acking — see `PLAY_COMMAND_TIMEOUT_MS` below;
52
+ * `stop`/`pause`/`resume`/`step` are synchronous and use the relay's
53
+ * generic 5s command timeout).
54
+ * - status: `GET /__editor/state`'s `playState`/`timeScale`/`seed`/
55
+ * `deterministic` fields — ALL real since D15/T-D15.6 (`collectState`
56
+ * reports the live session's `ctx.random.seed` and whether the manifest
57
+ * declares `determinism.seededRandom`).
58
+ * - gameplay-state listing/inspection, debug-command listing/invocation,
59
+ * input injection (including D15/T-D15.5's tick-indexed `atTick` and
60
+ * per-world `worldId`), time-scale control, and seed control (D15/
61
+ * T-D15.6) are REAL: relay cases `list-gameplay-state`/
62
+ * `inspect-gameplay-state`/`list-debug-commands`/`invoke-debug-command`/
63
+ * `inject-input`/`set-time-scale`/`set-seed` in `command-listener.ts` read
64
+ * the live session's game-scoped debug registry (`SystemAdapters.debug`),
65
+ * its per-world `InputManager`(s)/`GameLoop`, or its `ctx.random`.
66
+ * - active-world inspection remains a documented GAP: no command case, no
67
+ * capability (see `status-operations.ts`).
68
+ * - log discovery/read are file-native (`<projectRoot>/logs/play-*.jsonl`,
69
+ * written by `play-mode.ts`'s log-session flow) — read directly off disk
70
+ * by the `node`-hosted ops in `log-operations.ts`, no transport/relay
71
+ * involved at all. log FOLLOW describes the real, existing poll surface
72
+ * (`GET/POST /__editor/log-entries`, `POST /__editor/log-session`) for
73
+ * the CURRENTLY active play session — metadata only, same shape as B3's
74
+ * `editor.console.subscribe`.
75
+ */
76
+
77
+ import { existsSync, readFileSync } from 'node:fs';
78
+ import { homedir } from 'node:os';
79
+ import { join, resolve } from 'node:path';
80
+ import { z } from 'zod';
81
+ import { OperationError } from '../errors.js';
82
+ import type { ErrorDefinition } from '../registry.js';
83
+ import type { OperationContext } from '../types.js';
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Named timeouts
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /** Bound on discovering + probe-verifying live editor sessions (mirrors `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`). */
90
+ export const PLAY_SESSION_DISCOVERY_TIMEOUT_MS = 2000;
91
+ /** Bound on a single per-session liveness probe. */
92
+ export const PLAY_PROBE_TIMEOUT_MS = 1500;
93
+ /** Bound on a plain read (status, log-entries count, ...). */
94
+ export const PLAY_READ_TIMEOUT_MS = 3000;
95
+ /** Bound on a relayed pause/resume/stop/frame-step command — mirrors the relay's generic `COMMAND_TIMEOUT_MS` (editor-server.ts). */
96
+ export const PLAY_COMMAND_TIMEOUT_MS = 5000;
97
+ /**
98
+ * Bound on `play.start`. Mirrors the real relay's own
99
+ * `PLAY_COMMAND_TIMEOUT_MS = 120_000` (`packages/editor/server/editor-server.ts:385`)
100
+ * plus a small buffer, so the CLIENT-side bound never fires before the
101
+ * SERVER's own 120s window would — a real "editor connected but the game's
102
+ * async setup() didn't finish" timeout is reported by the relay itself
103
+ * (surfaced as `result.ok === false`), while THIS constant only bounds a
104
+ * transport that never resolves/responds at all (see `PLAY_START_TIMEOUT`
105
+ * below). Overridable per-call via the op's `readyTimeoutMs` input.
106
+ */
107
+ export const PLAY_START_TIMEOUT_MS = 125_000;
108
+
109
+ /** Thrown by `withPlayTimeout` when the wrapped promise doesn't settle in time. */
110
+ export class PlayTimeoutError extends Error {
111
+ constructor(label: string, ms: number) {
112
+ super(`${label} timed out after ${ms}ms`);
113
+ this.name = 'PlayTimeoutError';
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Race `promise` against a `ms`-bounded timer — the mechanism that makes
119
+ * "start returns only after readiness OR a named timeout" true (§8 B4 AC)
120
+ * regardless of what the underlying transport does (real fetch, or a test's
121
+ * deliberately never-resolving mock).
122
+ */
123
+ export function withPlayTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
124
+ return new Promise<T>((resolvePromise, reject) => {
125
+ const timer = setTimeout(() => reject(new PlayTimeoutError(label, ms)), ms);
126
+ promise.then(
127
+ (v) => {
128
+ clearTimeout(timer);
129
+ resolvePromise(v);
130
+ },
131
+ (err) => {
132
+ clearTimeout(timer);
133
+ reject(err instanceof Error ? err : new Error(String(err)));
134
+ },
135
+ );
136
+ });
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Shared shapes
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /** Same shape as `../editor/transport.ts`'s `EditorSessionInfo` — a play session IS an editor session today (see module jsdoc). */
144
+ export interface PlaySessionInfo {
145
+ port: number;
146
+ project: string | null;
147
+ pid: number | null;
148
+ }
149
+
150
+ export interface PlayCommandResult {
151
+ ok: boolean;
152
+ error?: string;
153
+ /** The relay's read-payload leg (Wave 5): the browser handler's structured
154
+ * result on success, or a structured `{ code, ... }` failure marker
155
+ * (registered-name lists, zod issues) on `ok: false`. */
156
+ data?: unknown;
157
+ }
158
+
159
+ /** Extract a relayed failure's structured `{ code, ...data }` marker
160
+ * (`command-listener.ts`'s `structuredErrorResult` shape), or null. Ops map
161
+ * these to their declared error codes — never by parsing message prose. */
162
+ export function relayErrorMarker(
163
+ result: PlayCommandResult,
164
+ ): { code: string; data: Record<string, unknown> } | null {
165
+ if (result.ok || typeof result.data !== 'object' || result.data === null) return null;
166
+ const { code, ...rest } = result.data as Record<string, unknown>;
167
+ if (typeof code !== 'string') return null;
168
+ return { code, data: rest };
169
+ }
170
+
171
+ /** Best-effort string[] out of relayed marker data (e.g. `registered` name
172
+ * lists) — never throws on a malformed wire payload. */
173
+ export function relayStringArray(value: unknown): string[] {
174
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
175
+ }
176
+
177
+ export type PlayState = 'stopped' | 'playing' | 'paused';
178
+
179
+ export interface PlayStatus {
180
+ playState: PlayState;
181
+ /** The running session's current `ctx.random` root seed (D15/T-D15.6:
182
+ * real since this track — reflects the manifest/query boot seed, or the
183
+ * last successful `play.seed.set`), or `null` when no first-party `Game`
184
+ * is running (nothing to report — never a fabricated value). */
185
+ seed: number | null;
186
+ /** True iff the running project's manifest declares
187
+ * `determinism.seededRandom` (D15) — i.e. `play.seed.set`/the
188
+ * `gameplay-rng-ban` scan/the dev-mode RNG trap all apply to it. `false`
189
+ * for an undeclared project even though `ctx.random` still exists (it
190
+ * just isn't a documented contract — see `docs/D15-DETERMINISM-DESIGN.md`
191
+ * §2.a). */
192
+ deterministic: boolean;
193
+ /** Currently-applied simulation time-scale, when known — null when never successfully set (see `control-operations.ts`'s honest gap). */
194
+ timeScale: number | null;
195
+ }
196
+
197
+ export interface ActiveWorldInfo {
198
+ worldId: string;
199
+ worldKind: string;
200
+ }
201
+
202
+ /** One raw play-mode log entry exactly as persisted (`editor-api.ts`'s `LogEntry`) — see `log-operations.ts` for the identity fields this module ADDS on top when reading. */
203
+ export interface RawPlayLogEntry {
204
+ t: number;
205
+ level: string;
206
+ source?: string;
207
+ sub?: string;
208
+ msg: string;
209
+ meta?: Record<string, unknown>;
210
+ }
211
+
212
+ export interface PlayLogFollowMetadata {
213
+ logEntriesUrl: string;
214
+ logSessionUrl: string;
215
+ bufferedLogCount: number;
216
+ }
217
+
218
+ export type InputInjectionKind = 'axis' | 'vector2' | 'pointerDelta' | 'pointerPosition';
219
+
220
+ /** Action-level injection (`kind: 'action'`, spec §3.2's `setVirtualAction` —
221
+ * PRIMARY since Wave 5: the honest, focus-gated path whose relay result
222
+ * carries `{delivered, reason?}`) plus the four legacy named-test-source
223
+ * shapes (`InputManager.injectAxis` family, read back only through declared
224
+ * `test_*` bindings). `atTick` (D15/T-D15.5, action-kind only) defers the
225
+ * actuation to `InputManager.scheduleActionAtTick` instead of applying it
226
+ * immediately — see `input-operations.ts`'s module jsdoc. `worldId` (D15
227
+ * review objection 2's fix, all kinds) targets a SPECIFIC world's
228
+ * `InputManager` — omitted, it resolves to the same default world every
229
+ * door (this relay AND `window.__vgai.input.*`) now shares. */
230
+ export type InputInjectionRequest =
231
+ | {
232
+ kind: 'action';
233
+ action: string;
234
+ value: boolean | number | { x: number; y: number };
235
+ atTick?: number;
236
+ worldId?: string;
237
+ }
238
+ | {
239
+ kind: InputInjectionKind;
240
+ sourceId: string;
241
+ value: number | { x: number; y: number };
242
+ worldId?: string;
243
+ };
244
+
245
+ /**
246
+ * The transport seam every `play.*` op's `impl` goes through (except the
247
+ * file-native log discover/read ops, which never need a session at all).
248
+ * Real production dispatch uses `HttpPlayTransport`; tests inject a fake via
249
+ * `ctx['playTransport']`.
250
+ *
251
+ * Every method that has no real wire support returns `undefined` (never a
252
+ * fabricated success) — the honest-gap convention `../editor/transport.ts`
253
+ * established for `openStory`/`getCamera`/`getHierarchy`.
254
+ */
255
+ export interface PlayTransport {
256
+ listSessions(timeoutMs: number): Promise<PlaySessionInfo[]>;
257
+ /** `seed` (D15/T-D15.6, optional) — relayed as `cmd['seed']` in the `'play'`
258
+ * command body; `command-listener.ts`'s `'play'` case threads it into
259
+ * `enterPlayMode`'s explicit-config seed leg. Omitted, boot seeding falls
260
+ * back to the manifest/`?vgai-seed=` precedence unchanged. */
261
+ start(session: PlaySessionInfo, timeoutMs: number, seed?: number): Promise<PlayCommandResult>;
262
+ stop(session: PlaySessionInfo, timeoutMs: number): Promise<PlayCommandResult>;
263
+ pause(session: PlaySessionInfo, timeoutMs: number): Promise<PlayCommandResult>;
264
+ resume(session: PlaySessionInfo, timeoutMs: number): Promise<PlayCommandResult>;
265
+ frameStep(session: PlaySessionInfo, timeoutMs: number): Promise<PlayCommandResult>;
266
+ getStatus(session: PlaySessionInfo, timeoutMs: number): Promise<PlayStatus | undefined>;
267
+ /** Honest gap against the real transport — see `status-operations.ts`. */
268
+ getActiveWorld(session: PlaySessionInfo, timeoutMs: number): Promise<ActiveWorldInfo | undefined>;
269
+ getLogFollowMetadata(
270
+ session: PlaySessionInfo,
271
+ timeoutMs: number,
272
+ ): Promise<PlayLogFollowMetadata | undefined>;
273
+ /** Real since Wave 5 (relay case `inject-input`); `undefined` only against
274
+ * a stale editor page (UNKNOWN_COMMAND_TYPE marker). On success `data`
275
+ * carries `{delivered, reason?}` for action-level injections. */
276
+ injectInput(
277
+ session: PlaySessionInfo,
278
+ request: InputInjectionRequest,
279
+ timeoutMs: number,
280
+ ): Promise<PlayCommandResult | undefined>;
281
+ /**
282
+ * D15/T-D15.6 (`docs/D15-DETERMINISM-DESIGN.md` §2.d): real since this
283
+ * track — relay case `set-seed` (`command-listener.ts`) reaching the live
284
+ * session's `ctx.random.reseed(seed)` through `play-mode.ts`'s
285
+ * `getPlayRuntimeAccess()`. FUTURE DRAWS ONLY (reseeding mid-run can never
286
+ * make an already-diverged session reproducible — for that, seed at
287
+ * session start instead). `undefined` only against a stale editor page
288
+ * (`UNKNOWN_COMMAND_TYPE` marker); a structured `DETERMINISM_NOT_DECLARED`
289
+ * failure (`data.code`) when the running project's manifest doesn't
290
+ * declare `determinism.seededRandom`.
291
+ */
292
+ setSeed(
293
+ session: PlaySessionInfo,
294
+ seed: number,
295
+ timeoutMs: number,
296
+ ): Promise<PlayCommandResult | undefined>;
297
+ /** Real since Wave 5 (relay case `set-time-scale`, reaching
298
+ * `GameLoop.timeScale`); `undefined` only against a stale editor page. */
299
+ setTimeScale(
300
+ session: PlaySessionInfo,
301
+ timeScale: number,
302
+ timeoutMs: number,
303
+ ): Promise<PlayCommandResult | undefined>;
304
+ /** Real since Wave 5 (relay case `inspect-gameplay-state`): success `data`
305
+ * is `{ state: Record<string, unknown> | null }` (null when the running
306
+ * game exposes no debug adapter); `undefined` only against a stale editor
307
+ * page. Structured failures (`STATE_PROVIDER_NOT_FOUND`) ride
308
+ * `data.code`. */
309
+ getGameplayState(
310
+ session: PlaySessionInfo,
311
+ keys: string[] | undefined,
312
+ timeoutMs: number,
313
+ ): Promise<PlayCommandResult | undefined>;
314
+ /** Real since Wave 5 (relay case `list-gameplay-state`): success `data` is
315
+ * `{ providers: {name, tier}[] }`. `undefined` = stale editor page. */
316
+ listGameplayState(
317
+ session: PlaySessionInfo,
318
+ timeoutMs: number,
319
+ ): Promise<PlayCommandResult | undefined>;
320
+ /** Real since Wave 5 (relay case `list-debug-commands`): success `data` is
321
+ * `{ commands: DebugCommandInfo[] }` (name/description/argsJsonSchema/
322
+ * locus). `undefined` = stale editor page. */
323
+ listDebugCommands(
324
+ session: PlaySessionInfo,
325
+ timeoutMs: number,
326
+ ): Promise<PlayCommandResult | undefined>;
327
+ /** Real since Wave 5 (relay case `invoke-debug-command`): success `data` is
328
+ * `{ result: unknown }`; structured failures (`DEBUG_COMMAND_NOT_REGISTERED`/
329
+ * `DEBUG_COMMAND_ARGS_INVALID`/`DEBUG_COMMAND_FAILED`) ride `data.code`.
330
+ * `undefined` = stale editor page. */
331
+ invokeDebugCommand(
332
+ session: PlaySessionInfo,
333
+ name: string,
334
+ args: unknown[],
335
+ timeoutMs: number,
336
+ ): Promise<PlayCommandResult | undefined>;
337
+ /**
338
+ * D15/T-D15.4 (`docs/D15-DETERMINISM-DESIGN.md` §2.b): real since this
339
+ * track — relay case `run-ticks` (`command-listener.ts`) reaching the live
340
+ * session's `GameInternal.runTicks` through `play-mode.ts`'s
341
+ * `getPlayRuntimeAccess()`. Success is a plain `{ok: true}` (no data leg);
342
+ * `undefined` only against a stale editor page (`UNKNOWN_COMMAND_TYPE`
343
+ * marker). Structured failures (`RUN_TICKS_PAUSED`, `RUN_TICKS_UNAVAILABLE`)
344
+ * ride `data.code`.
345
+ */
346
+ runTicks(
347
+ session: PlaySessionInfo,
348
+ n: number,
349
+ render: 'last' | 'all' | 'none' | undefined,
350
+ timeoutMs: number,
351
+ ): Promise<PlayCommandResult | undefined>;
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Real transport
356
+ // ---------------------------------------------------------------------------
357
+
358
+ interface RegistrySessionEntry {
359
+ project: string | null;
360
+ port: number;
361
+ pid: number;
362
+ startedAt: string;
363
+ }
364
+
365
+ function isRegistrySessionEntry(v: unknown): v is RegistrySessionEntry {
366
+ if (typeof v !== 'object' || v === null) return false;
367
+ const s = v as Record<string, unknown>;
368
+ return (
369
+ (typeof s['project'] === 'string' || s['project'] === null) &&
370
+ typeof s['port'] === 'number' &&
371
+ typeof s['pid'] === 'number' &&
372
+ typeof s['startedAt'] === 'string'
373
+ );
374
+ }
375
+
376
+ function pidAlive(pid: number): boolean {
377
+ try {
378
+ process.kill(pid, 0);
379
+ return true;
380
+ } catch {
381
+ return false;
382
+ }
383
+ }
384
+
385
+ /** Deliberate light duplicate of `../editor/transport.ts`'s identical reader — see module jsdoc. */
386
+ function readRegisteredSessions(): RegistrySessionEntry[] {
387
+ const registryFile = join(homedir(), '.vgai', 'editor-sessions.json');
388
+ if (!existsSync(registryFile)) return [];
389
+ try {
390
+ const raw: unknown = JSON.parse(readFileSync(registryFile, 'utf8'));
391
+ return Array.isArray(raw)
392
+ ? raw.filter(isRegistrySessionEntry).filter((s) => pidAlive(s.pid))
393
+ : [];
394
+ } catch {
395
+ return [];
396
+ }
397
+ }
398
+
399
+ async function fetchJson(url: string, timeoutMs: number): Promise<unknown | undefined> {
400
+ try {
401
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
402
+ if (!res.ok) return undefined;
403
+ return await res.json();
404
+ } catch {
405
+ return undefined;
406
+ }
407
+ }
408
+
409
+ async function postJson(
410
+ url: string,
411
+ body: unknown,
412
+ timeoutMs: number,
413
+ ): Promise<{ status: number; json: unknown } | undefined> {
414
+ try {
415
+ const res = await fetch(url, {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/json' },
418
+ body: JSON.stringify(body),
419
+ signal: AbortSignal.timeout(timeoutMs),
420
+ });
421
+ const json = await res.json().catch(() => undefined);
422
+ return { status: res.status, json };
423
+ } catch {
424
+ return undefined;
425
+ }
426
+ }
427
+
428
+ function baseUrl(session: PlaySessionInfo): string {
429
+ return `http://localhost:${session.port}`;
430
+ }
431
+
432
+ async function relayCommand(
433
+ session: PlaySessionInfo,
434
+ command: Record<string, unknown>,
435
+ timeoutMs: number,
436
+ ): Promise<PlayCommandResult> {
437
+ const result = await postJson(`${baseUrl(session)}/__editor/command`, command, timeoutMs);
438
+ if (!result) return { ok: false, error: 'Command relay unreachable.' };
439
+ const json = result.json as Record<string, unknown> | undefined;
440
+ // `commandResponseFor` (editor server-utils.ts) SPREADS the browser
441
+ // handler's `data` into the response body at the top level, beside
442
+ // `ok`/`error` — the same wire shape `HttpEditorTransport.sendCommand`
443
+ // reads. Collect everything else back into `data` here so ops see one
444
+ // uniform payload field.
445
+ if (json?.['ok']) {
446
+ const { ok: _ok, ...data } = json;
447
+ return Object.keys(data).length > 0 ? { ok: true, data } : { ok: true };
448
+ }
449
+ const { ok: _ok, error, ...data } = json ?? {};
450
+ return {
451
+ ok: false,
452
+ error: typeof error === 'string' ? error : `Command relay returned status ${result.status}.`,
453
+ ...(Object.keys(data).length > 0 ? { data } : {}),
454
+ };
455
+ }
456
+
457
+ /** Translate the browser's structured version-skew marker (`data.code ===
458
+ * 'UNKNOWN_COMMAND_TYPE'`, sent by `command-listener.ts`'s `default:` case)
459
+ * into `undefined` — the undefined-means-unsupported convention — so ops
460
+ * throw their declared `*_UNSUPPORTED` codes against a stale editor page. */
461
+ function unlessUnknownCommandType(result: PlayCommandResult): PlayCommandResult | undefined {
462
+ return relayErrorMarker(result)?.code === 'UNKNOWN_COMMAND_TYPE' ? undefined : result;
463
+ }
464
+
465
+ /** The real, production transport — HTTP against a live `vgai edit` dev server. */
466
+ export class HttpPlayTransport implements PlayTransport {
467
+ async listSessions(timeoutMs: number): Promise<PlaySessionInfo[]> {
468
+ const registered = readRegisteredSessions();
469
+ const perProbeTimeout = Math.min(PLAY_PROBE_TIMEOUT_MS, Math.max(200, timeoutMs));
470
+ const probes = await Promise.all(
471
+ registered.map(async (s) => {
472
+ const body = await fetchJson(
473
+ `http://localhost:${s.port}/__editor/project`,
474
+ perProbeTimeout,
475
+ );
476
+ if (body === undefined) return undefined;
477
+ const project = (body as { project?: { path?: string } | null }).project?.path ?? null;
478
+ const info: PlaySessionInfo = { port: s.port, project, pid: s.pid };
479
+ return info;
480
+ }),
481
+ );
482
+ return probes.filter((p): p is PlaySessionInfo => p !== undefined);
483
+ }
484
+
485
+ async start(
486
+ session: PlaySessionInfo,
487
+ timeoutMs: number,
488
+ seed?: number,
489
+ ): Promise<PlayCommandResult> {
490
+ return relayCommand(
491
+ session,
492
+ { type: 'play', ...(seed !== undefined ? { seed } : {}) },
493
+ timeoutMs,
494
+ );
495
+ }
496
+
497
+ async stop(session: PlaySessionInfo, timeoutMs: number): Promise<PlayCommandResult> {
498
+ return relayCommand(session, { type: 'stop' }, timeoutMs);
499
+ }
500
+
501
+ async pause(session: PlaySessionInfo, timeoutMs: number): Promise<PlayCommandResult> {
502
+ return relayCommand(session, { type: 'pause' }, timeoutMs);
503
+ }
504
+
505
+ async resume(session: PlaySessionInfo, timeoutMs: number): Promise<PlayCommandResult> {
506
+ return relayCommand(session, { type: 'resume' }, timeoutMs);
507
+ }
508
+
509
+ async frameStep(session: PlaySessionInfo, timeoutMs: number): Promise<PlayCommandResult> {
510
+ return relayCommand(session, { type: 'step' }, timeoutMs);
511
+ }
512
+
513
+ async getStatus(session: PlaySessionInfo, timeoutMs: number): Promise<PlayStatus | undefined> {
514
+ const body = (await fetchJson(`${baseUrl(session)}/__editor/state`, timeoutMs)) as
515
+ | {
516
+ playState?: PlayState;
517
+ timeScale?: number | null;
518
+ seed?: number | null;
519
+ deterministic?: boolean;
520
+ }
521
+ | undefined;
522
+ if (!body) return undefined;
523
+ // timeScale is real since Wave 5 (`collectState` reports the live loop's
524
+ // value; null while not playing or from an older editor page). seed/
525
+ // deterministic are real since D15/T-D15.6 (`collectState` reports the
526
+ // live session's `ctx.random.seed` + whether the manifest declares
527
+ // `determinism.seededRandom`); still null/false against an older editor
528
+ // page whose `collectState` predates these fields.
529
+ return {
530
+ playState: body.playState ?? 'stopped',
531
+ seed: typeof body.seed === 'number' ? body.seed : null,
532
+ deterministic: body.deterministic === true,
533
+ timeScale: typeof body.timeScale === 'number' ? body.timeScale : null,
534
+ };
535
+ }
536
+
537
+ async getActiveWorld(
538
+ _session: PlaySessionInfo,
539
+ _timeoutMs: number,
540
+ ): Promise<ActiveWorldInfo | undefined> {
541
+ // `collectState()` (command-listener.ts) carries no world field today —
542
+ // honest gap, never a fabricated world id (see status-operations.ts).
543
+ return undefined;
544
+ }
545
+
546
+ async getLogFollowMetadata(
547
+ session: PlaySessionInfo,
548
+ timeoutMs: number,
549
+ ): Promise<PlayLogFollowMetadata | undefined> {
550
+ const body = (await fetchJson(`${baseUrl(session)}/__editor/log-entries`, timeoutMs)) as
551
+ | { entries?: unknown[] }
552
+ | undefined;
553
+ if (!body) return undefined;
554
+ return {
555
+ logEntriesUrl: `${baseUrl(session)}/__editor/log-entries`,
556
+ logSessionUrl: `${baseUrl(session)}/__editor/log-session`,
557
+ bufferedLogCount: Array.isArray(body.entries) ? body.entries.length : 0,
558
+ };
559
+ }
560
+
561
+ async injectInput(
562
+ session: PlaySessionInfo,
563
+ request: InputInjectionRequest,
564
+ timeoutMs: number,
565
+ ): Promise<PlayCommandResult | undefined> {
566
+ return unlessUnknownCommandType(
567
+ await relayCommand(session, { type: 'inject-input', ...request }, timeoutMs),
568
+ );
569
+ }
570
+
571
+ async setSeed(
572
+ session: PlaySessionInfo,
573
+ seed: number,
574
+ timeoutMs: number,
575
+ ): Promise<PlayCommandResult | undefined> {
576
+ return unlessUnknownCommandType(
577
+ await relayCommand(session, { type: 'set-seed', seed }, timeoutMs),
578
+ );
579
+ }
580
+
581
+ async setTimeScale(
582
+ session: PlaySessionInfo,
583
+ timeScale: number,
584
+ timeoutMs: number,
585
+ ): Promise<PlayCommandResult | undefined> {
586
+ return unlessUnknownCommandType(
587
+ await relayCommand(session, { type: 'set-time-scale', timeScale }, timeoutMs),
588
+ );
589
+ }
590
+
591
+ async getGameplayState(
592
+ session: PlaySessionInfo,
593
+ keys: string[] | undefined,
594
+ timeoutMs: number,
595
+ ): Promise<PlayCommandResult | undefined> {
596
+ return unlessUnknownCommandType(
597
+ await relayCommand(
598
+ session,
599
+ { type: 'inspect-gameplay-state', ...(keys !== undefined ? { keys } : {}) },
600
+ timeoutMs,
601
+ ),
602
+ );
603
+ }
604
+
605
+ async listGameplayState(
606
+ session: PlaySessionInfo,
607
+ timeoutMs: number,
608
+ ): Promise<PlayCommandResult | undefined> {
609
+ return unlessUnknownCommandType(
610
+ await relayCommand(session, { type: 'list-gameplay-state' }, timeoutMs),
611
+ );
612
+ }
613
+
614
+ async listDebugCommands(
615
+ session: PlaySessionInfo,
616
+ timeoutMs: number,
617
+ ): Promise<PlayCommandResult | undefined> {
618
+ return unlessUnknownCommandType(
619
+ await relayCommand(session, { type: 'list-debug-commands' }, timeoutMs),
620
+ );
621
+ }
622
+
623
+ async invokeDebugCommand(
624
+ session: PlaySessionInfo,
625
+ name: string,
626
+ args: unknown[],
627
+ timeoutMs: number,
628
+ ): Promise<PlayCommandResult | undefined> {
629
+ return unlessUnknownCommandType(
630
+ await relayCommand(session, { type: 'invoke-debug-command', name, args }, timeoutMs),
631
+ );
632
+ }
633
+
634
+ async runTicks(
635
+ session: PlaySessionInfo,
636
+ n: number,
637
+ render: 'last' | 'all' | 'none' | undefined,
638
+ timeoutMs: number,
639
+ ): Promise<PlayCommandResult | undefined> {
640
+ return unlessUnknownCommandType(
641
+ await relayCommand(
642
+ session,
643
+ { type: 'run-ticks', n, ...(render !== undefined ? { render } : {}) },
644
+ timeoutMs,
645
+ ),
646
+ );
647
+ }
648
+ }
649
+
650
+ const defaultTransport = new HttpPlayTransport();
651
+
652
+ /** Resolve the transport to use: `ctx['playTransport']` when injected (tests), else the shared real transport. */
653
+ export function getPlayTransport(ctx: OperationContext): PlayTransport {
654
+ const injected = ctx['playTransport'];
655
+ return (injected as PlayTransport | undefined) ?? defaultTransport;
656
+ }
657
+
658
+ // ---------------------------------------------------------------------------
659
+ // EDITOR_NOT_RUNNING (reused verbatim — play targets an editor session too)
660
+ // + deterministic session resolution
661
+ // ---------------------------------------------------------------------------
662
+
663
+ export const PLAY_RUNTIME_NOT_AVAILABLE_ERROR: ErrorDefinition = {
664
+ code: 'EDITOR_NOT_RUNNING',
665
+ summary:
666
+ 'No live, responsive editor session is available to host a play runtime (none running, ' +
667
+ 'none matching the requested target, or discovery timed out). The SAME code B3 uses ' +
668
+ '(`editor.*`) — today, a play runtime only ever exists inside a connected editor session.',
669
+ data: z.object({ editorUrl: z.string().optional() }),
670
+ };
671
+
672
+ function canonicalize(p: string): string {
673
+ return resolve(p);
674
+ }
675
+
676
+ function portOf(url: string): number | undefined {
677
+ try {
678
+ const port = new URL(url).port;
679
+ return port ? Number(port) : undefined;
680
+ } catch {
681
+ return undefined;
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Deterministic session selection for `play.*` ops — see module jsdoc for
687
+ * why this duplicates (rather than imports) `../editor/transport.ts`'s
688
+ * `resolveEditorSession`. Precedence is IDENTICAL: explicit `ctx.editorUrl`
689
+ * > `ctx.projectRoot` match > lowest live port > `EDITOR_NOT_RUNNING`.
690
+ */
691
+ export async function resolvePlaySession(
692
+ ctx: OperationContext,
693
+ transport: Pick<PlayTransport, 'listSessions'>,
694
+ ): Promise<PlaySessionInfo> {
695
+ const editorUrl = ctx.editorUrl;
696
+ const notRunning = (): never => {
697
+ throw new OperationError('EDITOR_NOT_RUNNING', 'No editor connected to host a play runtime.', {
698
+ ...(editorUrl !== undefined ? { editorUrl } : {}),
699
+ });
700
+ };
701
+
702
+ let sessions: PlaySessionInfo[];
703
+ try {
704
+ sessions = await withPlayTimeout(
705
+ transport.listSessions(PLAY_SESSION_DISCOVERY_TIMEOUT_MS),
706
+ PLAY_SESSION_DISCOVERY_TIMEOUT_MS,
707
+ 'play session discovery',
708
+ );
709
+ } catch {
710
+ return notRunning();
711
+ }
712
+ if (sessions.length === 0) return notRunning();
713
+
714
+ if (editorUrl !== undefined) {
715
+ const port = portOf(editorUrl);
716
+ const match = sessions.find((s) => s.port === port);
717
+ if (!match) return notRunning();
718
+ return match;
719
+ }
720
+
721
+ if (ctx.projectRoot !== undefined) {
722
+ const canon = canonicalize(ctx.projectRoot);
723
+ const match = sessions.find((s) => s.project !== null && canonicalize(s.project) === canon);
724
+ if (match) return match;
725
+ }
726
+
727
+ return [...sessions].sort((a, b) => a.port - b.port)[0]!;
728
+ }