@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,288 @@
1
+ /**
2
+ * MCP projection of the operation registry (B8, §8 B8).
3
+ *
4
+ * The SAME `OperationRegistry` behind the SDK, the CLI (B6), and the HTTP
5
+ * projection is registered here as MCP TOOLS (and, for a principled subset,
6
+ * MCP RESOURCES). Every tool is derived from a `listOperations()` summary —
7
+ * name, description, and a Zod-derived JSON Schema for its input — so adding
8
+ * an operation to the SDK gives it an MCP tool for free, just as it gives it
9
+ * a CLI subcommand (B6) and an HTTP route for free.
10
+ *
11
+ * TRANSPORT — HAND-ROLLED, NOT `@modelcontextprotocol/sdk`. That package is
12
+ * NOT installed in this repo (confirmed: no `@modelcontextprotocol/*` in any
13
+ * package.json or node_modules). §8 B8 asks only that MCP tools be registered
14
+ * from the same operation definitions and stay an optional projection; it does
15
+ * not mandate a particular library or wire transport. So I implement the MCP
16
+ * JSON-RPC 2.0 message shape directly (`initialize`, `tools/list`,
17
+ * `tools/call`, `resources/list`, `resources/read`) via this projection's
18
+ * `handle` method — the derivation of tools/resources from the registry is
19
+ * what matters here, and it is transport-independent. If the official SDK is
20
+ * later added, only a transport wrapper around `handle` changes; the
21
+ * derivation below does not.
22
+ *
23
+ * `handle` is IN-PROCESS ONLY today — it takes a parsed JSON-RPC request and
24
+ * returns a response object, which the tests (and any embedding host) drive
25
+ * directly; there is no stdio/HTTP-SSE transport bound to it yet. A real
26
+ * stdio/SSE transport wrapper is a tracked follow-up.
27
+ *
28
+ * RESOURCE-vs-TOOL DECISION (§8 B8 AC: "Read-only state is exposed as
29
+ * resources when that is more appropriate than a tool"):
30
+ * - An operation is exposed as a TOOL always (tools can take arguments; a
31
+ * parameterised read like `project.scene.read` NEEDS its `scenePath`
32
+ * argument, which an MCP resource read — identified only by a URI, with no
33
+ * arguments — cannot carry).
34
+ * - An operation is ADDITIONALLY exposed as a RESOURCE exactly when it is
35
+ * read-only (`mutates: false`) AND its input schema accepts the empty
36
+ * object `{}` (no required arguments) — i.e. it names a piece of readable
37
+ * STATE that a client can `resources/read` by URI alone, with nothing to
38
+ * parameterise. `project.status` and `cinematic.capabilities` qualify;
39
+ * `project.scene.read` (needs `scenePath`) does not and stays tool-only.
40
+ * A resource's URI is `vgai://op/<name>`; reading it dispatches the op with
41
+ * `{}` and returns the SAME structured result the tool call would.
42
+ *
43
+ * ERROR FIDELITY: a `tools/call` (or `resources/read`) whose dispatch fails
44
+ * returns the registry's `{ ok: false, error }` outcome verbatim inside the
45
+ * tool result (with `isError: true`), so `error.code` over MCP is byte-for-
46
+ * byte the code the SDK, CLI, and HTTP projection produce for the same input.
47
+ */
48
+
49
+ import { z } from 'zod';
50
+ import { CORE_ERROR_CODES } from '../errors.js';
51
+ import { operations as defaultRegistry } from '../index.js';
52
+ import type { OperationOutcome, OperationRegistry, OperationSummary } from '../registry.js';
53
+ import type { OperationContext } from '../types.js';
54
+
55
+ /** URI scheme/prefix under which read-only-state operations are also exposed as MCP resources. */
56
+ export const MCP_RESOURCE_PREFIX = 'vgai://op/';
57
+
58
+ /** One MCP tool descriptor, as returned by `tools/list`. */
59
+ export interface McpTool {
60
+ name: string;
61
+ description: string;
62
+ inputSchema: unknown;
63
+ }
64
+
65
+ /** One MCP resource descriptor, as returned by `resources/list`. */
66
+ export interface McpResource {
67
+ uri: string;
68
+ name: string;
69
+ description: string;
70
+ mimeType: string;
71
+ }
72
+
73
+ export interface McpProjectionOptions {
74
+ /** Registry to project. Defaults to the shared `operations` registry (same one the CLI/HTTP projections use). */
75
+ registry?: OperationRegistry;
76
+ /** Static context handed to every dispatch. Ignored when `createContext` is given. */
77
+ context?: OperationContext;
78
+ /** Per-call context factory (wins over `context`). */
79
+ createContext?: () => OperationContext | Promise<OperationContext>;
80
+ }
81
+
82
+ /** The Zod-derived JSON Schema for an op's input — the SAME conversion the HTTP manifest uses. */
83
+ export function toolInputSchema(summary: OperationSummary): unknown {
84
+ return z.toJSONSchema(summary.input, { unrepresentable: 'any' });
85
+ }
86
+
87
+ /** Derive one MCP tool from an operation summary: dotted name (mirrors §5.7 hierarchy), description, Zod-derived schema. */
88
+ export function operationToTool(summary: OperationSummary): McpTool {
89
+ return {
90
+ name: summary.name,
91
+ description: `${summary.summary}\n\n${summary.description}`.trim(),
92
+ inputSchema: toolInputSchema(summary),
93
+ };
94
+ }
95
+
96
+ /** True when a read-only op takes no required input, so it names readable STATE addressable by URI alone (resource-worthy). See module jsdoc. */
97
+ export function isResourceEligible(summary: OperationSummary): boolean {
98
+ if (summary.mutates) return false;
99
+ return summary.input.safeParse({}).success;
100
+ }
101
+
102
+ /** Derive the MCP resource descriptor for a resource-eligible op. */
103
+ export function operationToResource(summary: OperationSummary): McpResource {
104
+ return {
105
+ uri: `${MCP_RESOURCE_PREFIX}${summary.name}`,
106
+ name: summary.name,
107
+ description: summary.summary,
108
+ mimeType: 'application/json',
109
+ };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // JSON-RPC 2.0 message shape (the MCP wire contract, hand-rolled)
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export interface JsonRpcRequest {
117
+ jsonrpc: '2.0';
118
+ id?: string | number | null;
119
+ method: string;
120
+ params?: unknown;
121
+ }
122
+
123
+ export interface JsonRpcResponse {
124
+ jsonrpc: '2.0';
125
+ id: string | number | null;
126
+ result?: unknown;
127
+ error?: { code: number; message: string; data?: unknown };
128
+ }
129
+
130
+ /** JSON-RPC error codes used by this projection (the MCP-relevant subset of the spec's reserved range). */
131
+ const RPC_METHOD_NOT_FOUND = -32601;
132
+ const RPC_INVALID_PARAMS = -32602;
133
+
134
+ function rpcResult(id: string | number | null, result: unknown): JsonRpcResponse {
135
+ return { jsonrpc: '2.0', id, result };
136
+ }
137
+
138
+ function rpcError(
139
+ id: string | number | null,
140
+ code: number,
141
+ message: string,
142
+ data?: unknown,
143
+ ): JsonRpcResponse {
144
+ return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } };
145
+ }
146
+
147
+ /**
148
+ * Wrap an `OperationOutcome` as an MCP tool-call result. MCP tool results are
149
+ * `{ content: [...], isError?, structuredContent? }`; we put the outcome JSON
150
+ * as a `text` content block (so a model sees it) AND — crucially for machine
151
+ * consumers and the contract test — pass it through verbatim in
152
+ * `structuredContent` (the real MCP spec field name for a machine-readable
153
+ * tool result) so `error.code` survives unchanged. `isError` mirrors
154
+ * `!outcome.ok`.
155
+ */
156
+ export function outcomeToToolResult(outcome: OperationOutcome): {
157
+ content: { type: 'text'; text: string }[];
158
+ isError: boolean;
159
+ structuredContent: OperationOutcome;
160
+ } {
161
+ return {
162
+ content: [{ type: 'text', text: JSON.stringify(outcome) }],
163
+ isError: !outcome.ok,
164
+ structuredContent: outcome,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * The MCP projection object: derive tools/resources from the registry and
170
+ * handle MCP JSON-RPC messages. `handle` is the single entry point a
171
+ * transport (or a test) drives.
172
+ */
173
+ export interface McpProjection {
174
+ registry: OperationRegistry;
175
+ listTools(): McpTool[];
176
+ listResources(): McpResource[];
177
+ callTool(name: string, args: unknown): Promise<OperationOutcome>;
178
+ readResource(uri: string): Promise<OperationOutcome>;
179
+ handle(request: JsonRpcRequest): Promise<JsonRpcResponse>;
180
+ }
181
+
182
+ export function createMcpProjection(options: McpProjectionOptions = {}): McpProjection {
183
+ const registry = options.registry ?? defaultRegistry;
184
+
185
+ async function resolveContext(): Promise<OperationContext> {
186
+ if (options.createContext) return options.createContext();
187
+ return options.context ?? {};
188
+ }
189
+
190
+ function listTools(): McpTool[] {
191
+ return registry
192
+ .listOperations()
193
+ .map(operationToTool)
194
+ .sort((a, b) => a.name.localeCompare(b.name));
195
+ }
196
+
197
+ function listResources(): McpResource[] {
198
+ return registry
199
+ .listOperations()
200
+ .filter(isResourceEligible)
201
+ .map(operationToResource)
202
+ .sort((a, b) => a.uri.localeCompare(b.uri));
203
+ }
204
+
205
+ async function callTool(name: string, args: unknown): Promise<OperationOutcome> {
206
+ // dispatch already returns OPERATION_NOT_FOUND for an unknown name and
207
+ // INVALID_INPUT for a bad-shaped `args` — same codes as SDK/CLI/HTTP.
208
+ return registry.dispatch(name, args ?? {}, await resolveContext());
209
+ }
210
+
211
+ async function readResource(uri: string): Promise<OperationOutcome> {
212
+ if (!uri.startsWith(MCP_RESOURCE_PREFIX)) {
213
+ return {
214
+ ok: false,
215
+ error: {
216
+ code: CORE_ERROR_CODES.OPERATION_NOT_FOUND,
217
+ message: `Resource URI must start with ${MCP_RESOURCE_PREFIX}; got "${uri}".`,
218
+ data: { uri },
219
+ },
220
+ };
221
+ }
222
+ const name = uri.slice(MCP_RESOURCE_PREFIX.length);
223
+ const summary = registry.listOperations().find((op) => op.name === name);
224
+ if (!summary || !isResourceEligible(summary)) {
225
+ return {
226
+ ok: false,
227
+ error: {
228
+ code: CORE_ERROR_CODES.OPERATION_NOT_FOUND,
229
+ message: `No read-only resource is exposed at "${uri}".`,
230
+ data: { uri },
231
+ },
232
+ };
233
+ }
234
+ return registry.dispatch(name, {}, await resolveContext());
235
+ }
236
+
237
+ async function handle(request: JsonRpcRequest): Promise<JsonRpcResponse> {
238
+ const id = request.id ?? null;
239
+ const params = (request.params ?? {}) as Record<string, unknown>;
240
+
241
+ switch (request.method) {
242
+ case 'initialize':
243
+ return rpcResult(id, {
244
+ protocolVersion: '2024-11-05',
245
+ capabilities: { tools: {}, resources: {} },
246
+ serverInfo: { name: 'vgai-operation-registry', version: '0.1.0' },
247
+ });
248
+
249
+ case 'tools/list':
250
+ return rpcResult(id, { tools: listTools() });
251
+
252
+ case 'tools/call': {
253
+ const name = params['name'];
254
+ if (typeof name !== 'string') {
255
+ return rpcError(id, RPC_INVALID_PARAMS, 'tools/call requires a string `name`.');
256
+ }
257
+ const outcome = await callTool(name, params['arguments']);
258
+ return rpcResult(id, outcomeToToolResult(outcome));
259
+ }
260
+
261
+ case 'resources/list':
262
+ return rpcResult(id, { resources: listResources() });
263
+
264
+ case 'resources/read': {
265
+ const uri = params['uri'];
266
+ if (typeof uri !== 'string') {
267
+ return rpcError(id, RPC_INVALID_PARAMS, 'resources/read requires a string `uri`.');
268
+ }
269
+ const outcome = await readResource(uri);
270
+ return rpcResult(id, {
271
+ contents: [
272
+ {
273
+ uri,
274
+ mimeType: 'application/json',
275
+ text: JSON.stringify(outcome),
276
+ },
277
+ ],
278
+ isError: !outcome.ok,
279
+ });
280
+ }
281
+
282
+ default:
283
+ return rpcError(id, RPC_METHOD_NOT_FOUND, `Unknown MCP method "${request.method}".`);
284
+ }
285
+ }
286
+
287
+ return { registry, listTools, listResources, callTool, readResource, handle };
288
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * B1's sample operation(s). Per the spec ("you may register a SMALL set of
3
+ * real or stub-but-typed ops to exercise the registry ... the point is the
4
+ * REGISTRY MACHINERY + validation, not implementing all of B2-B8"), this
5
+ * exercises a real filesystem read (project.status) without duplicating
6
+ * B2/B3/B4/B5's real work.
7
+ *
8
+ * B1 originally also registered TWO further stand-ins here:
9
+ * - `editor.selection.get` (an editor-browser-host op that always threw
10
+ * EDITOR_NOT_RUNNING) — B3 (`./editor/selection-operations.ts`) now
11
+ * registers the REAL `editor.selection.get` under the same name.
12
+ * - `cinematic.render` (a node/longRunning op that always threw
13
+ * NOT_IMPLEMENTED, to exercise the review-hardened execution-host
14
+ * metadata shape end to end) — B5 (`./cinematic/render-operations.ts`)
15
+ * now registers the REAL `cinematic.render` under the same name.
16
+ * Both stand-ins were removed from here to avoid a duplicate-name
17
+ * registration; see each namespace's own `index.ts` for the live
18
+ * implementation.
19
+ */
20
+
21
+ import { existsSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { z } from 'zod';
24
+ import { OperationError } from './errors.js';
25
+ import { defineOperation, type OperationRegistry } from './registry.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // project.status — real, file-native (B2 project.* stand-in)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export const ProjectStatusInput = z
32
+ .object({})
33
+ .describe('No input — status is computed for ctx.projectRoot.');
34
+
35
+ export const ProjectStatusResult = z
36
+ .object({
37
+ projectRoot: z.string().describe('Absolute path this status was computed for.'),
38
+ hasProject: z
39
+ .boolean()
40
+ .describe('True when a vgai.game.json manifest exists directly under projectRoot.'),
41
+ })
42
+ .describe('Discovery result for the project at ctx.projectRoot.');
43
+
44
+ export const projectStatus = defineOperation({
45
+ name: 'project.status',
46
+ summary: 'Report whether a vgai project manifest exists at the execution context project root.',
47
+ description:
48
+ 'File-native discovery operation (B2 project.* namespace stand-in for B1). Reads ' +
49
+ 'ctx.projectRoot from disk — no editor process required — and reports whether a ' +
50
+ 'vgai.game.json manifest is present there.',
51
+ input: ProjectStatusInput,
52
+ result: ProjectStatusResult,
53
+ errors: [
54
+ {
55
+ code: 'NO_PROJECT_ROOT',
56
+ summary: 'dispatch() was called without ctx.projectRoot.',
57
+ data: z.object({}).describe('No additional data.'),
58
+ },
59
+ ],
60
+ requires: { project: true },
61
+ host: 'node',
62
+ mutates: false,
63
+ supportsDryRun: false,
64
+ permission: { risk: 'read', summary: "Reads one file's existence under projectRoot; no writes." },
65
+ async impl(_input, ctx) {
66
+ if (!ctx.projectRoot) {
67
+ throw new OperationError(
68
+ 'NO_PROJECT_ROOT',
69
+ 'dispatch() was called without ctx.projectRoot.',
70
+ {},
71
+ );
72
+ }
73
+ return {
74
+ projectRoot: ctx.projectRoot,
75
+ hasProject: existsSync(join(ctx.projectRoot, 'vgai.game.json')),
76
+ };
77
+ },
78
+ });
79
+
80
+ /** Register every B1 sample operation onto `registry`. */
81
+ export function registerBuiltinOperations(registry: OperationRegistry): void {
82
+ registry.register(projectStatus);
83
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `play.seed.set` / `play.timeScale.set`
3
+ * (B4, §8 B4 "deterministic seed and time-scale control").
4
+ *
5
+ * TIME-SCALE is REAL since Wave 5 (docs/SYNTHETIC-PLAYER-SPEC.md §3.5): the
6
+ * `set-time-scale` relay case (`command-listener.ts`) reaches the live
7
+ * session's `GameLoop.timeScale` (`packages/engine/src/core/game-loop.ts`,
8
+ * a live get/set property clamped to `[0, 8]`) through `play-mode.ts`'s
9
+ * `getPlayRuntimeAccess()`, and `collectState` reports the applied value
10
+ * back through `play.status`. `TIME_SCALE_CONTROL_UNSUPPORTED` now fires
11
+ * only against a stale editor page (the structured `UNKNOWN_COMMAND_TYPE`
12
+ * marker, translated to `undefined` by the transport) — or a session where
13
+ * play isn't running, which surfaces as `COMMAND_FAILED`.
14
+ *
15
+ * SEED is REAL since D15/T-D15.6 (`docs/D15-DETERMINISM-DESIGN.md` §2.d) —
16
+ * no longer an honest gap. `play.seed.set` reaches the live session's
17
+ * `ctx.random.reseed(seed)` (`packages/engine/src/core/seeded-random.ts`,
18
+ * built on T-D15.1's core) through the `set-seed` relay case
19
+ * (`command-listener.ts`) and `play-mode.ts`'s `getPlayRuntimeAccess()`.
20
+ * Semantics are FUTURE-DRAWS-ONLY, matching `SeededRandom.reseed`'s own
21
+ * contract exactly: reseeding mid-run re-derives every live stream so every
22
+ * draw AFTER this call is reproducible from the new seed, but it can never
23
+ * make an already-diverged session's PAST draws reproducible — for a fully
24
+ * reproducible run, pass the seed at session start instead (manifest
25
+ * `determinism.defaultSeed` / `?vgai-seed=` / `play.start`'s `seed` field /
26
+ * `vgai play --seed` / a probe fixture seed option). Throws the declared
27
+ * `DETERMINISM_NOT_DECLARED` when the running project's manifest doesn't
28
+ * declare `determinism.seededRandom` — reseeding a project with no
29
+ * documented determinism contract would silently imply one exists.
30
+ * `SEED_CONTROL_UNSUPPORTED` now fires only against a stale editor page (the
31
+ * structured `UNKNOWN_COMMAND_TYPE` marker) — the SAME shape
32
+ * `TIME_SCALE_CONTROL_UNSUPPORTED_ERROR` uses for its own version-skew case,
33
+ * immediately below.
34
+ */
35
+
36
+ import { z } from 'zod';
37
+ import { OperationError } from '../errors.js';
38
+ import { defineOperation, type OperationRegistry } from '../registry.js';
39
+ import {
40
+ getPlayTransport,
41
+ PLAY_COMMAND_TIMEOUT_MS,
42
+ PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
43
+ relayErrorMarker,
44
+ resolvePlaySession,
45
+ withPlayTimeout,
46
+ } from './transport.js';
47
+
48
+ const COMMAND_FAILED_ERROR = {
49
+ code: 'COMMAND_FAILED',
50
+ summary: 'The connected editor rejected or failed to execute the relayed control command.',
51
+ data: z.object({ message: z.string() }),
52
+ } as const;
53
+
54
+ const SEED_CONTROL_UNSUPPORTED_ERROR = {
55
+ code: 'SEED_CONTROL_UNSUPPORTED',
56
+ summary:
57
+ 'The connected editor page predates the set-seed relay command (version skew, matched on ' +
58
+ 'the structured UNKNOWN_COMMAND_TYPE marker) — reload the editor tab. Never silently ' +
59
+ 'reported as success.',
60
+ data: z.object({}),
61
+ } as const;
62
+
63
+ const DETERMINISM_NOT_DECLARED_ERROR = {
64
+ code: 'DETERMINISM_NOT_DECLARED',
65
+ summary:
66
+ "The running project's manifest does not declare determinism.seededRandom (D15) — " +
67
+ 'play.seed.set has no documented determinism contract to reseed against. Declare the ' +
68
+ 'block (docs/D15-DETERMINISM-DESIGN.md §2.a) to opt in.',
69
+ data: z.object({}),
70
+ } as const;
71
+
72
+ const TIME_SCALE_CONTROL_UNSUPPORTED_ERROR = {
73
+ code: 'TIME_SCALE_CONTROL_UNSUPPORTED',
74
+ summary:
75
+ 'The connected editor page predates the set-time-scale relay command (version skew, ' +
76
+ 'matched on the structured UNKNOWN_COMMAND_TYPE marker) — reload the editor tab. Never ' +
77
+ 'silently reported as success.',
78
+ data: z.object({}),
79
+ } as const;
80
+
81
+ const OkResult = z.object({ ok: z.literal(true) });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // play.seed.set
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const PlaySeedSetInput = z.object({
88
+ seed: z.number().int().describe('Deterministic seed value to apply to the running play session.'),
89
+ });
90
+
91
+ export const playSeedSet = defineOperation({
92
+ name: 'play.seed.set',
93
+ summary: 'Live-reseed a running play session (future draws only).',
94
+ description:
95
+ "Reaches the live session's ctx.random.reseed(seed) (D15/T-D15.6) through the set-seed relay " +
96
+ 'case — future draws only, never a fabricated retroactive reproducibility (see module ' +
97
+ 'jsdoc). Throws DETERMINISM_NOT_DECLARED when the running project has no ' +
98
+ 'determinism.seededRandom manifest declaration, or SEED_CONTROL_UNSUPPORTED against a stale ' +
99
+ 'editor page.',
100
+ input: PlaySeedSetInput,
101
+ result: OkResult,
102
+ errors: [
103
+ PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
104
+ SEED_CONTROL_UNSUPPORTED_ERROR,
105
+ DETERMINISM_NOT_DECLARED_ERROR,
106
+ COMMAND_FAILED_ERROR,
107
+ ],
108
+ requires: { editor: true, play: true },
109
+ host: 'runtime-page',
110
+ mutates: true,
111
+ supportsDryRun: false,
112
+ permission: {
113
+ risk: 'write',
114
+ summary: "Would set the live play session's RNG seed, when supported.",
115
+ },
116
+ async impl(input, ctx) {
117
+ const transport = getPlayTransport(ctx);
118
+ const session = await resolvePlaySession(ctx, transport);
119
+ const result = await withPlayTimeout(
120
+ transport.setSeed(session, input.seed, PLAY_COMMAND_TIMEOUT_MS),
121
+ PLAY_COMMAND_TIMEOUT_MS,
122
+ 'play.seed.set',
123
+ ).catch(() => undefined);
124
+ if (!result) {
125
+ throw new OperationError(
126
+ 'SEED_CONTROL_UNSUPPORTED',
127
+ 'The connected editor page predates the set-seed relay command — reload it.',
128
+ {},
129
+ );
130
+ }
131
+ if (!result.ok) {
132
+ const marker = relayErrorMarker(result);
133
+ const message = result.error ?? 'set-seed command failed';
134
+ if (marker?.code === 'DETERMINISM_NOT_DECLARED') {
135
+ throw new OperationError('DETERMINISM_NOT_DECLARED', message, {});
136
+ }
137
+ throw new OperationError('COMMAND_FAILED', message, { message });
138
+ }
139
+ return { ok: true as const };
140
+ },
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // play.timeScale.set
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const PlayTimeScaleSetInput = z.object({
148
+ timeScale: z
149
+ .number()
150
+ .min(0)
151
+ .max(8)
152
+ .describe('Simulation time-scale to apply (clamped [0,8] to match GameLoop.timeScale).'),
153
+ });
154
+
155
+ export const playTimeScaleSet = defineOperation({
156
+ name: 'play.timeScale.set',
157
+ summary: "Set a running play session's simulation time-scale.",
158
+ description:
159
+ "Sets the live session's GameLoop.timeScale (clamped [0,8]) through the set-time-scale " +
160
+ 'relay command; the applied value is reflected by a subsequent play.status read. Throws ' +
161
+ 'TIME_SCALE_CONTROL_UNSUPPORTED against a stale editor page (see module jsdoc).',
162
+ input: PlayTimeScaleSetInput,
163
+ result: OkResult,
164
+ errors: [
165
+ PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
166
+ TIME_SCALE_CONTROL_UNSUPPORTED_ERROR,
167
+ COMMAND_FAILED_ERROR,
168
+ ],
169
+ requires: { editor: true, play: true },
170
+ host: 'runtime-page',
171
+ mutates: true,
172
+ supportsDryRun: false,
173
+ permission: {
174
+ risk: 'write',
175
+ summary: "Would set the live play session's simulation time-scale, when supported.",
176
+ },
177
+ async impl(input, ctx) {
178
+ const transport = getPlayTransport(ctx);
179
+ const session = await resolvePlaySession(ctx, transport);
180
+ const result = await withPlayTimeout(
181
+ transport.setTimeScale(session, input.timeScale, PLAY_COMMAND_TIMEOUT_MS),
182
+ PLAY_COMMAND_TIMEOUT_MS,
183
+ 'play.timeScale.set',
184
+ ).catch(() => undefined);
185
+ if (!result) {
186
+ throw new OperationError(
187
+ 'TIME_SCALE_CONTROL_UNSUPPORTED',
188
+ "The connected editor has no wire-level command to change a live play session's " +
189
+ 'time-scale today.',
190
+ {},
191
+ );
192
+ }
193
+ if (!result.ok) {
194
+ throw new OperationError('COMMAND_FAILED', result.error ?? 'set-time-scale command failed', {
195
+ message: result.error ?? 'set-time-scale command failed',
196
+ });
197
+ }
198
+ return { ok: true as const };
199
+ },
200
+ });
201
+
202
+ export function registerControlOperations(registry: OperationRegistry): void {
203
+ registry.register(playSeedSet);
204
+ registry.register(playTimeScaleSet);
205
+ }