@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,271 @@
1
+ /**
2
+ * `play.start` / `play.pause` / `play.resume` / `play.stop` / `play.frameStep`
3
+ * (B4, §8 B4 "start, pause, resume, stop, and frame-step").
4
+ *
5
+ * All five are genuinely wired end to end against the real transport:
6
+ * `{type:'play'|'pause'|'resume'|'stop'|'step'}` are existing,
7
+ * already-handled cases in the browser's `handleCommand` switch
8
+ * (`packages/editor/src/command-listener.ts`), relayed through the same
9
+ * `POST /__editor/command` mechanism B3's `editor.*` ops use.
10
+ *
11
+ * READINESS (§8 B4 AC "Start returns only after the runtime reports
12
+ * readiness or a named timeout"): `case 'play'` in `handleCommand`
13
+ * `await`s `enterPlayMode()` — which itself awaits the ENTIRE async game
14
+ * boot (asset loads, procedural worldgen, `createGameRuntime`/
15
+ * `mountManifestWorlds`) — before acking back over
16
+ * `POST /__editor/command-result`. That ack is what unblocks the relay's
17
+ * HTTP response. So `play.start`'s `impl` genuinely does not resolve before
18
+ * the runtime is ready: it `await`s the transport call, and the transport
19
+ * call (real or mocked) does not resolve until the browser acks. The op
20
+ * additionally wraps that await in `withPlayTimeout(..., readyTimeoutMs)` so a
21
+ * transport that NEVER resolves (dead relay, hung browser) still fails with
22
+ * a NAMED `PLAY_START_TIMEOUT` error rather than hanging forever — this is
23
+ * the "or a named timeout" half of the AC. `readyTimeoutMs` defaults to
24
+ * `PLAY_START_TIMEOUT_MS` (125s — 5s above the real relay's own 120s
25
+ * `PLAY_COMMAND_TIMEOUT_MS`, so the server's own timeout/error message wins
26
+ * first in the real-transport case; this op's timeout only fires against a
27
+ * transport that doesn't even respond).
28
+ *
29
+ * STOP CLEANUP (§8 B4 AC "Stop cleans up the launched runtime"): `case
30
+ * 'stop'` calls `exitPlayMode()` synchronously, which (per `play-mode.ts`)
31
+ * removes the game canvas, disposes the live `GameSession` (Rapier world,
32
+ * RAF loop, WebGL context), ends the log-persistence session, and restores
33
+ * editor input — that full teardown is what a successful `play.stop`
34
+ * result means; nothing further needs to run client-side.
35
+ */
36
+
37
+ import { z } from 'zod';
38
+ import { OperationError } from '../errors.js';
39
+ import { defineOperation, type OperationRegistry } from '../registry.js';
40
+ import {
41
+ getPlayTransport,
42
+ PLAY_COMMAND_TIMEOUT_MS,
43
+ PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
44
+ PLAY_START_TIMEOUT_MS,
45
+ PlayTimeoutError,
46
+ resolvePlaySession,
47
+ withPlayTimeout,
48
+ } from './transport.js';
49
+
50
+ const COMMAND_FAILED_ERROR = {
51
+ code: 'COMMAND_FAILED',
52
+ summary:
53
+ 'The connected editor rejected or failed to execute the relayed play command (e.g. scene ' +
54
+ "validation failed, or the game's setup() threw).",
55
+ data: z.object({ message: z.string() }),
56
+ } as const;
57
+
58
+ const PLAY_START_TIMEOUT_ERROR = {
59
+ code: 'PLAY_START_TIMEOUT',
60
+ summary:
61
+ 'The runtime did not report readiness within the bound (named timeout, never an unbounded hang).',
62
+ data: z.object({ timeoutMs: z.number() }),
63
+ } as const;
64
+
65
+ const OkResult = z.object({ ok: z.literal(true) });
66
+
67
+ async function relayAndCheck(
68
+ op: string,
69
+ send: () => Promise<{ ok: boolean; error?: string }>,
70
+ timeoutMs: number,
71
+ ): Promise<void> {
72
+ const result = await withPlayTimeout(send(), timeoutMs, op).catch((err: unknown) => ({
73
+ ok: false,
74
+ error: err instanceof Error ? err.message : String(err),
75
+ }));
76
+ if (!result.ok) {
77
+ throw new OperationError('COMMAND_FAILED', result.error ?? `${op} command failed`, {
78
+ message: result.error ?? `${op} command failed`,
79
+ });
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // play.start
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const PlayStartInput = z.object({
88
+ readyTimeoutMs: z
89
+ .number()
90
+ .int()
91
+ .positive()
92
+ .optional()
93
+ .describe(
94
+ 'Override the max wait for runtime readiness before failing with the named ' +
95
+ 'PLAY_START_TIMEOUT error. Defaults to PLAY_START_TIMEOUT_MS (125s) — see module jsdoc.',
96
+ ),
97
+ seed: z
98
+ .number()
99
+ .int()
100
+ .optional()
101
+ .describe(
102
+ 'D15/T-D15.6 — the explicit-config leg of the boot-time deterministic-seed precedence ' +
103
+ '(beats manifest.determinism.defaultSeed/?vgai-seed= on the editor page URL); baked in ' +
104
+ 'before any world mounts (`enterPlayMode`/`mountManifestWorlds`, not a post-hoc reseed). ' +
105
+ 'Mirrors `vgai play --seed <n>` — see docs/D15-DETERMINISM-DESIGN.md §2.d.',
106
+ ),
107
+ });
108
+
109
+ export const playStart = defineOperation({
110
+ name: 'play.start',
111
+ summary: 'Start (or restart) play mode in a connected editor session and wait for readiness.',
112
+ description:
113
+ 'Relays {type:"play"} through POST /__editor/command — an existing, already-handled case ' +
114
+ "whose ack is gated on the game's ENTIRE async setup() (see module jsdoc), never acked early. " +
115
+ 'A transport that never responds fails with the named PLAY_START_TIMEOUT error instead of hanging.',
116
+ input: PlayStartInput,
117
+ result: OkResult,
118
+ errors: [PLAY_RUNTIME_NOT_AVAILABLE_ERROR, PLAY_START_TIMEOUT_ERROR, COMMAND_FAILED_ERROR],
119
+ requires: { editor: true, play: true },
120
+ host: 'runtime-page',
121
+ mutates: true,
122
+ supportsDryRun: false,
123
+ permission: {
124
+ risk: 'write',
125
+ summary: 'Launches (or restarts) the play runtime inside the connected editor session.',
126
+ },
127
+ async impl(input, ctx) {
128
+ const transport = getPlayTransport(ctx);
129
+ const session = await resolvePlaySession(ctx, transport);
130
+ const timeoutMs = input.readyTimeoutMs ?? PLAY_START_TIMEOUT_MS;
131
+ let result: { ok: boolean; error?: string };
132
+ try {
133
+ result = await withPlayTimeout(
134
+ transport.start(session, timeoutMs, input.seed),
135
+ timeoutMs,
136
+ 'play.start',
137
+ );
138
+ } catch (err) {
139
+ if (err instanceof PlayTimeoutError) {
140
+ throw new OperationError(
141
+ 'PLAY_START_TIMEOUT',
142
+ `The runtime did not report readiness within ${timeoutMs}ms.`,
143
+ { timeoutMs },
144
+ );
145
+ }
146
+ throw err;
147
+ }
148
+ if (!result.ok) {
149
+ throw new OperationError('COMMAND_FAILED', result.error ?? 'play command failed', {
150
+ message: result.error ?? 'play command failed',
151
+ });
152
+ }
153
+ return { ok: true as const };
154
+ },
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // play.pause / play.resume / play.stop / play.frameStep
159
+ // ---------------------------------------------------------------------------
160
+
161
+ const NoInput = z.object({});
162
+
163
+ export const playPause = defineOperation({
164
+ name: 'play.pause',
165
+ summary: 'Pause the running play session in a connected editor.',
166
+ description: 'Relays {type:"pause"} through POST /__editor/command (already-handled case).',
167
+ input: NoInput,
168
+ result: OkResult,
169
+ errors: [PLAY_RUNTIME_NOT_AVAILABLE_ERROR, COMMAND_FAILED_ERROR],
170
+ requires: { editor: true, play: true },
171
+ host: 'runtime-page',
172
+ mutates: true,
173
+ supportsDryRun: false,
174
+ permission: { risk: 'write', summary: 'Pauses the live play runtime only.' },
175
+ async impl(_input, ctx) {
176
+ const transport = getPlayTransport(ctx);
177
+ const session = await resolvePlaySession(ctx, transport);
178
+ await relayAndCheck(
179
+ 'play.pause',
180
+ () => transport.pause(session, PLAY_COMMAND_TIMEOUT_MS),
181
+ PLAY_COMMAND_TIMEOUT_MS,
182
+ );
183
+ return { ok: true as const };
184
+ },
185
+ });
186
+
187
+ export const playResume = defineOperation({
188
+ name: 'play.resume',
189
+ summary: 'Resume a paused play session in a connected editor.',
190
+ description: 'Relays {type:"resume"} through POST /__editor/command (already-handled case).',
191
+ input: NoInput,
192
+ result: OkResult,
193
+ errors: [PLAY_RUNTIME_NOT_AVAILABLE_ERROR, COMMAND_FAILED_ERROR],
194
+ requires: { editor: true, play: true },
195
+ host: 'runtime-page',
196
+ mutates: true,
197
+ supportsDryRun: false,
198
+ permission: { risk: 'write', summary: 'Resumes the live play runtime only.' },
199
+ async impl(_input, ctx) {
200
+ const transport = getPlayTransport(ctx);
201
+ const session = await resolvePlaySession(ctx, transport);
202
+ await relayAndCheck(
203
+ 'play.resume',
204
+ () => transport.resume(session, PLAY_COMMAND_TIMEOUT_MS),
205
+ PLAY_COMMAND_TIMEOUT_MS,
206
+ );
207
+ return { ok: true as const };
208
+ },
209
+ });
210
+
211
+ export const playStop = defineOperation({
212
+ name: 'play.stop',
213
+ summary: 'Stop play mode in a connected editor and clean up the launched runtime.',
214
+ description:
215
+ 'Relays {type:"stop"} through POST /__editor/command, which calls exitPlayMode() — full ' +
216
+ 'synchronous teardown of the launched runtime (canvas, GameSession, log-persistence session, ' +
217
+ 'editor input restoration). See module jsdoc.',
218
+ input: NoInput,
219
+ result: OkResult,
220
+ errors: [PLAY_RUNTIME_NOT_AVAILABLE_ERROR, COMMAND_FAILED_ERROR],
221
+ requires: { editor: true, play: true },
222
+ host: 'runtime-page',
223
+ mutates: true,
224
+ supportsDryRun: false,
225
+ permission: {
226
+ risk: 'write',
227
+ summary: 'Stops and fully tears down the live play runtime.',
228
+ },
229
+ async impl(_input, ctx) {
230
+ const transport = getPlayTransport(ctx);
231
+ const session = await resolvePlaySession(ctx, transport);
232
+ await relayAndCheck(
233
+ 'play.stop',
234
+ () => transport.stop(session, PLAY_COMMAND_TIMEOUT_MS),
235
+ PLAY_COMMAND_TIMEOUT_MS,
236
+ );
237
+ return { ok: true as const };
238
+ },
239
+ });
240
+
241
+ export const playFrameStep = defineOperation({
242
+ name: 'play.frameStep',
243
+ summary: 'Advance a paused play session by exactly one frame.',
244
+ description: 'Relays {type:"step"} through POST /__editor/command (already-handled case).',
245
+ input: NoInput,
246
+ result: OkResult,
247
+ errors: [PLAY_RUNTIME_NOT_AVAILABLE_ERROR, COMMAND_FAILED_ERROR],
248
+ requires: { editor: true, play: true },
249
+ host: 'runtime-page',
250
+ mutates: true,
251
+ supportsDryRun: false,
252
+ permission: { risk: 'write', summary: 'Advances the live play runtime by one frame only.' },
253
+ async impl(_input, ctx) {
254
+ const transport = getPlayTransport(ctx);
255
+ const session = await resolvePlaySession(ctx, transport);
256
+ await relayAndCheck(
257
+ 'play.frameStep',
258
+ () => transport.frameStep(session, PLAY_COMMAND_TIMEOUT_MS),
259
+ PLAY_COMMAND_TIMEOUT_MS,
260
+ );
261
+ return { ok: true as const };
262
+ },
263
+ });
264
+
265
+ export function registerLifecycleOperations(registry: OperationRegistry): void {
266
+ registry.register(playStart);
267
+ registry.register(playPause);
268
+ registry.register(playResume);
269
+ registry.register(playStop);
270
+ registry.register(playFrameStep);
271
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `play.log.discover` / `play.log.read` / `play.log.follow`
3
+ * (B4, §8 B4 "structured log discovery/read/follow").
4
+ *
5
+ * discover/read are FILE-NATIVE (`host: 'node'`, `requires: {project: true}`)
6
+ * — they read `<projectRoot>/logs/play-*.jsonl` directly off disk, the same
7
+ * files `play-mode.ts`'s `startLogSession`/`flushLogEntries` write via
8
+ * `POST /__editor/log-session` + `/__editor/log-entries`
9
+ * (`packages/editor/server/editor-server.ts`). No editor session or
10
+ * transport is needed to list/read them — they persist after play stops,
11
+ * and reading them should work whether or not an editor is even running
12
+ * (reusing "the logs/play-*.jsonl reader" per this unit's own brief, taken
13
+ * literally: read the files).
14
+ *
15
+ * IDENTITY FIELDS (§8 B4 AC "Logs identify session, project, world, and
16
+ * simulation speed"): the real persisted `LogEntry` shape
17
+ * (`packages/editor/src/editor-api.ts`) is `{t, level, source?, sub?, msg,
18
+ * meta?}` — NO session/project/world/simSpeed fields exist in the file
19
+ * format today (a genuine gap against this AC in the CURRENT persistence
20
+ * layer, which predates it; closing it for real would mean threading these
21
+ * fields through `play-mode.ts`'s log sink and `editor-server.ts`'s
22
+ * append handler — both outside this unit's file ownership,
23
+ * packages/editor/src|server). `play.log.read` satisfies the AC honestly,
24
+ * without fabricating anything:
25
+ * - `session`: the log FILENAME itself (e.g. "play-2026-...jsonl") — the
26
+ * closest real identity a persisted play run has today.
27
+ * - `project`: `ctx.projectRoot`, always real (these ops require it).
28
+ * - `world`/`simSpeed`: read from each entry's own `meta.world` /
29
+ * `meta.simSpeed`, when the entry happens to carry them (`meta` is
30
+ * already a free-form bag in the real format) — `null` otherwise. This
31
+ * is the honest state of affairs: against TODAY's real files these are
32
+ * always `null` (the current sink never stamps them); a test fixture
33
+ * that DOES embed them in `meta` proves the read-through path for real.
34
+ *
35
+ * `play.log.follow` describes the REAL, existing poll-based subscription
36
+ * for the CURRENTLY ACTIVE play session's log (there is no push/SSE
37
+ * mechanism for log lines — `/__editor/events`'s SSE stream never carries
38
+ * them) — same "metadata only" contract B3's `editor.console.subscribe`
39
+ * established, `requires: {play: true}` because `activeLogFile` is only
40
+ * non-null while a play session is running.
41
+ */
42
+
43
+ import { readdirSync } from 'node:fs';
44
+ import { join } from 'node:path';
45
+ import { z } from 'zod';
46
+ import { OperationError } from '../errors.js';
47
+ import {
48
+ FILE_NOT_FOUND_ERROR,
49
+ NO_PROJECT_ROOT_ERROR,
50
+ PATH_OUTSIDE_PROJECT_ERROR,
51
+ readFileWithHash,
52
+ requireProjectRoot,
53
+ resolveProjectPath,
54
+ } from '../project/shared.js';
55
+ import { defineOperation, type OperationRegistry } from '../registry.js';
56
+ import {
57
+ getPlayTransport,
58
+ PLAY_READ_TIMEOUT_MS,
59
+ PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
60
+ resolvePlaySession,
61
+ withPlayTimeout,
62
+ } from './transport.js';
63
+
64
+ const PLAY_LOG_FOLLOW_UNAVAILABLE_ERROR = {
65
+ code: 'PLAY_LOG_FOLLOW_UNAVAILABLE',
66
+ summary: 'A session is connected but did not answer the log-entries read in time.',
67
+ data: z.object({}),
68
+ } as const;
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // play.log.discover
72
+ // ---------------------------------------------------------------------------
73
+
74
+ const PlayLogDiscoverInput = z
75
+ .object({})
76
+ .describe("No input — lists every persisted play-mode log file under the project's logs/ dir.");
77
+
78
+ const PlayLogDiscoverResult = z.object({
79
+ logFiles: z
80
+ .array(z.string())
81
+ .describe('Filenames (e.g. "play-2026-07-11T10-00-00.jsonl"), oldest first (name-sorted).'),
82
+ });
83
+
84
+ export const playLogDiscover = defineOperation({
85
+ name: 'play.log.discover',
86
+ summary: "List every persisted play-mode log file under the project's logs/ directory.",
87
+ description:
88
+ 'Reads <projectRoot>/logs/ directly off disk and filters play-*.jsonl (the exact naming ' +
89
+ "convention editor-server.ts's log-session handler writes). Works with no editor/play " +
90
+ 'session running — these files persist after play stops.',
91
+ input: PlayLogDiscoverInput,
92
+ result: PlayLogDiscoverResult,
93
+ errors: [NO_PROJECT_ROOT_ERROR],
94
+ requires: { project: true },
95
+ host: 'node',
96
+ mutates: false,
97
+ supportsDryRun: false,
98
+ permission: { risk: 'read', summary: "Lists the project's logs/ directory; no writes." },
99
+ async impl(_input, ctx) {
100
+ const projectRoot = requireProjectRoot(ctx);
101
+ const logsDir = join(projectRoot, 'logs');
102
+ let names: string[];
103
+ try {
104
+ names = readdirSync(logsDir);
105
+ } catch {
106
+ names = [];
107
+ }
108
+ const logFiles = names.filter((f) => f.startsWith('play-') && f.endsWith('.jsonl')).sort();
109
+ return { logFiles };
110
+ },
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // play.log.read
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const PlayLogEntrySchema = z.object({
118
+ t: z.number().describe('Entry timestamp (ms epoch), as persisted.'),
119
+ level: z.string(),
120
+ source: z.string().nullable(),
121
+ sub: z.string().nullable(),
122
+ msg: z.string(),
123
+ meta: z.record(z.string(), z.unknown()).nullable(),
124
+ session: z.string().describe('The log filename identifying this play run (see module jsdoc).'),
125
+ project: z.string().describe('Absolute project root this log belongs to.'),
126
+ world: z
127
+ .string()
128
+ .nullable()
129
+ .describe(
130
+ 'Active world id for this entry, read from meta.world when the writer stamped it — null ' +
131
+ "otherwise (today's real sink never stamps this; see module jsdoc).",
132
+ ),
133
+ simSpeed: z
134
+ .number()
135
+ .nullable()
136
+ .describe(
137
+ 'Simulation time-scale for this entry, read from meta.simSpeed when the writer stamped it ' +
138
+ "— null otherwise (today's real sink never stamps this; see module jsdoc).",
139
+ ),
140
+ });
141
+
142
+ const PlayLogReadInput = z.object({
143
+ file: z.string().describe('A filename returned by play.log.discover, project-relative to logs/.'),
144
+ offset: z
145
+ .number()
146
+ .int()
147
+ .nonnegative()
148
+ .optional()
149
+ .describe('Skip this many entries from the start. Defaults to 0.'),
150
+ limit: z
151
+ .number()
152
+ .int()
153
+ .positive()
154
+ .optional()
155
+ .describe('Max entries to return. Defaults to all remaining.'),
156
+ });
157
+
158
+ const PlayLogReadResult = z.object({
159
+ entries: z.array(PlayLogEntrySchema),
160
+ totalEntries: z.number().describe('Total entries in the file, regardless of offset/limit.'),
161
+ });
162
+
163
+ function isRecord(v: unknown): v is Record<string, unknown> {
164
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
165
+ }
166
+
167
+ /** Parse one raw JSONL line into a `PlayLogEntrySchema`-shaped entry, stamping the identity fields (see module jsdoc). */
168
+ function parseLogLine(
169
+ line: string,
170
+ session: string,
171
+ project: string,
172
+ ): z.infer<typeof PlayLogEntrySchema> {
173
+ const parsed: unknown = JSON.parse(line);
174
+ const rec = isRecord(parsed) ? parsed : {};
175
+ const meta = isRecord(rec['meta']) ? (rec['meta'] as Record<string, unknown>) : null;
176
+ const worldRaw = meta?.['world'];
177
+ const simSpeedRaw = meta?.['simSpeed'];
178
+ return {
179
+ t: typeof rec['t'] === 'number' ? rec['t'] : 0,
180
+ level: typeof rec['level'] === 'string' ? rec['level'] : 'log',
181
+ source: typeof rec['source'] === 'string' ? rec['source'] : null,
182
+ sub: typeof rec['sub'] === 'string' ? rec['sub'] : null,
183
+ msg: typeof rec['msg'] === 'string' ? rec['msg'] : '',
184
+ meta,
185
+ session,
186
+ project,
187
+ world: typeof worldRaw === 'string' ? worldRaw : null,
188
+ simSpeed: typeof simSpeedRaw === 'number' ? simSpeedRaw : null,
189
+ };
190
+ }
191
+
192
+ export const playLogRead = defineOperation({
193
+ name: 'play.log.read',
194
+ summary: 'Read entries from a persisted play-mode log file.',
195
+ description:
196
+ 'Reads <projectRoot>/logs/<file> directly off disk, parsing each JSONL line. Each returned ' +
197
+ 'entry is enriched with session (the log filename)/project (ctx.projectRoot) — both real — ' +
198
+ "plus world/simSpeed read from the entry's own meta bag when present, null otherwise (see " +
199
+ 'module jsdoc for why the raw persisted format has no dedicated fields for these yet).',
200
+ input: PlayLogReadInput,
201
+ result: PlayLogReadResult,
202
+ errors: [NO_PROJECT_ROOT_ERROR, PATH_OUTSIDE_PROJECT_ERROR, FILE_NOT_FOUND_ERROR],
203
+ requires: { project: true },
204
+ host: 'node',
205
+ mutates: false,
206
+ supportsDryRun: false,
207
+ permission: { risk: 'read', summary: 'Reads one file under logs/; no writes.' },
208
+ async impl(input, ctx) {
209
+ const projectRoot = requireProjectRoot(ctx);
210
+ const absPath = resolveProjectPath(projectRoot, join('logs', input.file));
211
+ const { raw } = readFileWithHash(absPath);
212
+ const lines = raw.split('\n').filter((l) => l.trim().length > 0);
213
+ const offset = input.offset ?? 0;
214
+ const limit = input.limit ?? lines.length;
215
+ const slice = lines.slice(offset, offset + limit);
216
+ const entries = slice.map((line) => parseLogLine(line, input.file, projectRoot));
217
+ return { entries, totalEntries: lines.length };
218
+ },
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // play.log.follow
223
+ // ---------------------------------------------------------------------------
224
+
225
+ const PlayLogFollowInput = z
226
+ .object({})
227
+ .describe("No input — describes how to poll-follow the CURRENTLY active play session's log.");
228
+
229
+ const PlayLogFollowResult = z.object({
230
+ logEntriesUrl: z
231
+ .string()
232
+ .describe('GET (read all buffered entries) / POST (append) — poll this to tail.'),
233
+ logSessionUrl: z
234
+ .string()
235
+ .describe('POST {action:"start"|"end"} — already bracketing the active play session.'),
236
+ bufferedLogCount: z
237
+ .number()
238
+ .describe('Live count of entries buffered for the active play session.'),
239
+ });
240
+
241
+ export const playLogFollow = defineOperation({
242
+ name: 'play.log.follow',
243
+ summary: "Describe how to poll-follow the currently active play session's log.",
244
+ description:
245
+ 'Metadata only (no push/SSE for log lines exists today): reports the real ' +
246
+ 'GET/POST /__editor/log-entries + /__editor/log-session endpoints for the CURRENTLY active ' +
247
+ 'play session, plus a live buffered-entry count — same contract as editor.console.subscribe.',
248
+ input: PlayLogFollowInput,
249
+ result: PlayLogFollowResult,
250
+ errors: [PLAY_RUNTIME_NOT_AVAILABLE_ERROR, PLAY_LOG_FOLLOW_UNAVAILABLE_ERROR],
251
+ requires: { editor: true, play: true },
252
+ host: 'runtime-page',
253
+ mutates: false,
254
+ supportsDryRun: false,
255
+ permission: { risk: 'read', summary: 'Reads live log-entry metadata only.' },
256
+ async impl(_input, ctx) {
257
+ const transport = getPlayTransport(ctx);
258
+ const session = await resolvePlaySession(ctx, transport);
259
+ const metadata = await withPlayTimeout(
260
+ transport.getLogFollowMetadata(session, PLAY_READ_TIMEOUT_MS),
261
+ PLAY_READ_TIMEOUT_MS,
262
+ 'play.log.follow',
263
+ ).catch(() => undefined);
264
+ if (!metadata) {
265
+ throw new OperationError(
266
+ 'PLAY_LOG_FOLLOW_UNAVAILABLE',
267
+ 'The connected editor session did not answer the log-entries read in time.',
268
+ {},
269
+ );
270
+ }
271
+ return metadata;
272
+ },
273
+ });
274
+
275
+ export function registerLogOperations(registry: OperationRegistry): void {
276
+ registry.register(playLogDiscover);
277
+ registry.register(playLogRead);
278
+ registry.register(playLogFollow);
279
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `play.runTicks` (D15/T-D15.4, `docs/D15-DETERMINISM-DESIGN.md` §2.b) — the
3
+ * editor door (b) of the same run-ticks primitive `window.__vgai.runTicks`
4
+ * (door a, `runtime/debug-bridge.ts`) exposes: synchronously fast-forward a
5
+ * running play session `n` fixed gameplay ticks, decoupled from wall clock.
6
+ *
7
+ * Wire: relay case `run-ticks` (`command-listener.ts`) reaching the live
8
+ * session's `GameInternal.runTicks` through `play-mode.ts`'s
9
+ * `getPlayRuntimeAccess()` — itself backed by the SAME
10
+ * `DebugRegistry.getRunTicksTarget()` the bridge calls, so behavior is
11
+ * byte-identical across both doors (D17). Against a stale editor page the
12
+ * transport translates the structured `UNKNOWN_COMMAND_TYPE` marker into
13
+ * `undefined` and this op throws the declared `RUN_TICKS_UNSUPPORTED`.
14
+ *
15
+ * DETERMINISM-agnostic by design (per the D15 doc): `runTicks` is a DRIVER —
16
+ * it makes tick count exact-by-construction on any hardware, but says
17
+ * nothing about whether draws/physics are actually reproducible (that is
18
+ * `determinism.seededRandom`'s contract, a different unit). This op never
19
+ * checks or requires that manifest block.
20
+ */
21
+
22
+ import { z } from 'zod';
23
+ import { OperationError } from '../errors.js';
24
+ import { defineOperation, type OperationRegistry } from '../registry.js';
25
+ import {
26
+ getPlayTransport,
27
+ PLAY_COMMAND_TIMEOUT_MS,
28
+ PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
29
+ relayErrorMarker,
30
+ resolvePlaySession,
31
+ withPlayTimeout,
32
+ } from './transport.js';
33
+
34
+ const RUN_TICKS_UNSUPPORTED_ERROR = {
35
+ code: 'RUN_TICKS_UNSUPPORTED',
36
+ summary:
37
+ 'The connected editor page predates the run-ticks relay command (version skew, matched on ' +
38
+ 'the structured UNKNOWN_COMMAND_TYPE marker) — reload the editor tab. Never silently ' +
39
+ 'reported as success.',
40
+ data: z.object({}),
41
+ } as const;
42
+
43
+ const RUN_TICKS_UNAVAILABLE_ERROR = {
44
+ code: 'RUN_TICKS_UNAVAILABLE',
45
+ summary:
46
+ 'The live play session has no first-party Game mounted yet (no run-ticks target wired) — ' +
47
+ 'wait for play.start to finish, or the session is running a foreign/ingested mount that ' +
48
+ 'exposes no Game root.',
49
+ data: z.object({}),
50
+ } as const;
51
+
52
+ const RUN_TICKS_PAUSED_ERROR = {
53
+ code: 'RUN_TICKS_PAUSED',
54
+ summary:
55
+ 'The live session is paused (Game.play.paused) — runTicks refuses outright rather than ' +
56
+ 'silently ignoring pause; use play.step to advance a paused/frozen world instead.',
57
+ data: z.object({}),
58
+ } as const;
59
+
60
+ const COMMAND_FAILED_ERROR = {
61
+ code: 'COMMAND_FAILED',
62
+ summary: 'The connected editor rejected or failed to execute the relayed run-ticks command.',
63
+ data: z.object({ message: z.string() }),
64
+ } as const;
65
+
66
+ const PlayRunTicksInput = z.object({
67
+ n: z
68
+ .number()
69
+ .int()
70
+ .min(0)
71
+ .describe('Number of fixed gameplay ticks to run synchronously, decoupled from wall clock.'),
72
+ render: z
73
+ .enum(['last', 'all', 'none'])
74
+ .optional()
75
+ .describe(
76
+ "'last' (default): render only the final tick. 'all': render every tick. 'none': never " +
77
+ 'render, not even the last tick. tick/simT and every non-render phase advance on EVERY ' +
78
+ 'tick regardless.',
79
+ ),
80
+ });
81
+
82
+ const PlayRunTicksResult = z.object({ ok: z.literal(true) });
83
+
84
+ export const playRunTicks = defineOperation({
85
+ name: 'play.runTicks',
86
+ summary: 'Synchronously fast-forward a running play session by n fixed gameplay ticks.',
87
+ description:
88
+ 'Drives GameInternal.runTicks n times, decoupled from wall clock and the loop accumulator — ' +
89
+ 'tick count is exact by construction on any hardware. Refuses (RUN_TICKS_PAUSED) while the ' +
90
+ 'session is paused; never bypasses the input focus gate. Throws RUN_TICKS_UNSUPPORTED ' +
91
+ 'against a stale editor page. DETERMINISM-agnostic: does not require determinism.seededRandom.',
92
+ input: PlayRunTicksInput,
93
+ result: PlayRunTicksResult,
94
+ errors: [
95
+ PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
96
+ RUN_TICKS_UNSUPPORTED_ERROR,
97
+ RUN_TICKS_UNAVAILABLE_ERROR,
98
+ RUN_TICKS_PAUSED_ERROR,
99
+ COMMAND_FAILED_ERROR,
100
+ ],
101
+ requires: { editor: true, play: true },
102
+ host: 'runtime-page',
103
+ mutates: true,
104
+ supportsDryRun: false,
105
+ permission: {
106
+ risk: 'write',
107
+ summary: 'Advances the live play session simulation by n fixed ticks (no user-visible input).',
108
+ },
109
+ async impl(input, ctx) {
110
+ const transport = getPlayTransport(ctx);
111
+ const session = await resolvePlaySession(ctx, transport);
112
+ const result = await withPlayTimeout(
113
+ transport.runTicks(session, input.n, input.render, PLAY_COMMAND_TIMEOUT_MS),
114
+ PLAY_COMMAND_TIMEOUT_MS,
115
+ 'play.runTicks',
116
+ ).catch(() => undefined);
117
+ if (!result) {
118
+ throw new OperationError(
119
+ 'RUN_TICKS_UNSUPPORTED',
120
+ 'The connected editor page predates the run-ticks relay command — reload it.',
121
+ {},
122
+ );
123
+ }
124
+ if (!result.ok) {
125
+ const marker = relayErrorMarker(result);
126
+ const message = result.error ?? 'run-ticks command failed';
127
+ if (marker?.code === 'RUN_TICKS_UNAVAILABLE') {
128
+ throw new OperationError('RUN_TICKS_UNAVAILABLE', message, {});
129
+ }
130
+ if (marker?.code === 'RUN_TICKS_PAUSED') {
131
+ throw new OperationError('RUN_TICKS_PAUSED', message, {});
132
+ }
133
+ throw new OperationError('COMMAND_FAILED', message, { message });
134
+ }
135
+ return { ok: true as const };
136
+ },
137
+ });
138
+
139
+ export function registerRunTicksOperations(registry: OperationRegistry): void {
140
+ registry.register(playRunTicks);
141
+ }