@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.
- package/package.json +27 -0
- package/src/cinematic/capabilities-operations.ts +128 -0
- package/src/cinematic/cue-operations.ts +198 -0
- package/src/cinematic/gsap-operations.ts +126 -0
- package/src/cinematic/index.ts +59 -0
- package/src/cinematic/preview-operations.ts +279 -0
- package/src/cinematic/preview-transport.ts +244 -0
- package/src/cinematic/render-operations.ts +409 -0
- package/src/cinematic/render-transport.ts +238 -0
- package/src/cinematic/theatre-operations.ts +306 -0
- package/src/editor/camera-operations.ts +169 -0
- package/src/editor/console-operations.ts +87 -0
- package/src/editor/hierarchy-operations.ts +95 -0
- package/src/editor/index.ts +62 -0
- package/src/editor/open-operations.ts +209 -0
- package/src/editor/screenshot-operations.ts +99 -0
- package/src/editor/selection-operations.ts +144 -0
- package/src/editor/session-operations.ts +73 -0
- package/src/editor/source-location-operations.ts +106 -0
- package/src/editor/transport.ts +647 -0
- package/src/errors.ts +72 -0
- package/src/http/http-projection.ts +349 -0
- package/src/http/index.ts +11 -0
- package/src/index.ts +67 -0
- package/src/mcp/index.ts +16 -0
- package/src/mcp/mcp-projection.ts +288 -0
- package/src/operations.ts +83 -0
- package/src/play/control-operations.ts +205 -0
- package/src/play/debug-command-operations.ts +245 -0
- package/src/play/index.ts +66 -0
- package/src/play/input-operations.ts +316 -0
- package/src/play/lifecycle-operations.ts +271 -0
- package/src/play/log-operations.ts +279 -0
- package/src/play/run-ticks-operations.ts +141 -0
- package/src/play/state-operations.ts +210 -0
- package/src/play/status-operations.ts +160 -0
- package/src/play/transport.ts +728 -0
- package/src/project/asset-operations.ts +243 -0
- package/src/project/component-operations.ts +337 -0
- package/src/project/discovery-operations.ts +269 -0
- package/src/project/entity-operations.ts +366 -0
- package/src/project/index.ts +55 -0
- package/src/project/input-map-operations.ts +233 -0
- package/src/project/manifest-operations.ts +355 -0
- package/src/project/scene-operations.ts +426 -0
- package/src/project/shared.ts +299 -0
- package/src/registry.ts +285 -0
- package/src/render/capabilities/ffmpeg.ts +141 -0
- package/src/render/index.ts +15 -0
- package/src/render/render-cinematic.ts +1847 -0
- 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
|
+
}
|