@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,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
|
+
}
|