@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,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `play.debugCommand.list` / `play.debugCommand.invoke`
|
|
3
|
+
* (Wave 5, docs/SYNTHETIC-PLAYER-SPEC.md §3.5 — the editor/CLI/MCP door onto
|
|
4
|
+
* `ctx.debug.registerCommand` registrations).
|
|
5
|
+
*
|
|
6
|
+
* Debug commands are FIXTURES, never proofs (D16): they set up state so a
|
|
7
|
+
* proof can then drive honest input and assert observable state — hence
|
|
8
|
+
* `invoke` is `mutates: true` / `permission.risk: 'write'`. `list` output
|
|
9
|
+
* carries each command's `locus` (`'client' | 'server'` — server-locus
|
|
10
|
+
* commands route through the room's `__vgai:debugCommand` handler and
|
|
11
|
+
* resolution means server ACK, not replication) and `argsJsonSchema` (the
|
|
12
|
+
* JSON-Schema projection of the command's declared Zod args tuple, the same
|
|
13
|
+
* shape the editor's Debug Console renders its invoke form from).
|
|
14
|
+
*
|
|
15
|
+
* Wire: relay cases `list-debug-commands`/`invoke-debug-command`
|
|
16
|
+
* (`command-listener.ts`) against the live session's game-scoped debug
|
|
17
|
+
* registry (`SystemAdapters.debug`). Against a stale editor page the
|
|
18
|
+
* transport translates the structured `UNKNOWN_COMMAND_TYPE` marker into
|
|
19
|
+
* `undefined` and both ops throw the declared `DEBUG_COMMANDS_UNSUPPORTED`.
|
|
20
|
+
* Registry-side failures arrive as structured `data.code` markers and map
|
|
21
|
+
* onto this file's declared codes — never by parsing message prose.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
import { OperationError } from '../errors.js';
|
|
26
|
+
import { defineOperation, type OperationRegistry } from '../registry.js';
|
|
27
|
+
import {
|
|
28
|
+
getPlayTransport,
|
|
29
|
+
PLAY_COMMAND_TIMEOUT_MS,
|
|
30
|
+
PLAY_READ_TIMEOUT_MS,
|
|
31
|
+
PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
|
|
32
|
+
relayErrorMarker,
|
|
33
|
+
relayStringArray,
|
|
34
|
+
resolvePlaySession,
|
|
35
|
+
withPlayTimeout,
|
|
36
|
+
} from './transport.js';
|
|
37
|
+
|
|
38
|
+
const DEBUG_COMMANDS_UNSUPPORTED_ERROR = {
|
|
39
|
+
code: 'DEBUG_COMMANDS_UNSUPPORTED',
|
|
40
|
+
summary:
|
|
41
|
+
'The connected editor page predates the debug-command relay commands (version skew, ' +
|
|
42
|
+
'matched on the structured UNKNOWN_COMMAND_TYPE marker) — reload the editor tab. ' +
|
|
43
|
+
'Never fabricated.',
|
|
44
|
+
data: z.object({}),
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
const DEBUG_COMMAND_NOT_REGISTERED_ERROR = {
|
|
48
|
+
code: 'DEBUG_COMMAND_NOT_REGISTERED',
|
|
49
|
+
summary:
|
|
50
|
+
'No debug command is registered under the requested name. `data.registered` lists every ' +
|
|
51
|
+
'command name the running game registered.',
|
|
52
|
+
data: z.object({ registered: z.array(z.string()) }),
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
const DEBUG_COMMAND_ARGS_INVALID_ERROR = {
|
|
56
|
+
code: 'DEBUG_COMMAND_ARGS_INVALID',
|
|
57
|
+
summary:
|
|
58
|
+
"The provided args failed the command's declared Zod tuple. `data.issues` carries the " +
|
|
59
|
+
'zod issues.',
|
|
60
|
+
data: z.object({ issues: z.array(z.unknown()) }),
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
const DEBUG_COMMAND_FAILED_ERROR = {
|
|
64
|
+
code: 'DEBUG_COMMAND_FAILED',
|
|
65
|
+
summary:
|
|
66
|
+
'The command executed and threw (data.cause), or its server-locus routing failed ' +
|
|
67
|
+
'(data.reason, e.g. no room connection / server timeout).',
|
|
68
|
+
data: z.record(z.string(), z.unknown()),
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
const COMMAND_FAILED_ERROR = {
|
|
72
|
+
code: 'COMMAND_FAILED',
|
|
73
|
+
summary: 'The connected editor rejected or failed to execute the relayed command.',
|
|
74
|
+
data: z.object({ message: z.string() }),
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// play.debugCommand.list
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const PlayDebugCommandListResult = z.object({
|
|
82
|
+
commands: z
|
|
83
|
+
.array(
|
|
84
|
+
z.object({
|
|
85
|
+
name: z.string().describe('Registered command name.'),
|
|
86
|
+
description: z.string().optional().describe('Author-provided description, when given.'),
|
|
87
|
+
argsJsonSchema: z
|
|
88
|
+
.unknown()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe("JSON-Schema projection of the command's declared Zod args tuple."),
|
|
91
|
+
locus: z
|
|
92
|
+
.enum(['client', 'server'])
|
|
93
|
+
.describe(
|
|
94
|
+
"Where the command executes: 'server' routes through the room's __vgai:debugCommand " +
|
|
95
|
+
'handler (resolution = server ack, not replication).',
|
|
96
|
+
),
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
.describe('Every debug command the running game registered, with locus and arg shapes.'),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export const playDebugCommandList = defineOperation({
|
|
103
|
+
name: 'play.debugCommand.list',
|
|
104
|
+
summary: 'List the debug commands a running play session registers, with locus and arg shapes.',
|
|
105
|
+
description:
|
|
106
|
+
'Enumerates ctx.debug.registerCommand registrations from the live editor play session via ' +
|
|
107
|
+
'the list-debug-commands relay command. Each entry carries its locus (client/server) and ' +
|
|
108
|
+
'the JSON-Schema projection of its declared args tuple. A running game with no debug ' +
|
|
109
|
+
'adapter lists an empty array; throws DEBUG_COMMANDS_UNSUPPORTED against a stale editor page.',
|
|
110
|
+
input: z.object({}),
|
|
111
|
+
result: PlayDebugCommandListResult,
|
|
112
|
+
errors: [
|
|
113
|
+
PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
|
|
114
|
+
DEBUG_COMMANDS_UNSUPPORTED_ERROR,
|
|
115
|
+
COMMAND_FAILED_ERROR,
|
|
116
|
+
],
|
|
117
|
+
requires: { editor: true, play: true },
|
|
118
|
+
host: 'runtime-page',
|
|
119
|
+
mutates: false,
|
|
120
|
+
supportsDryRun: false,
|
|
121
|
+
permission: {
|
|
122
|
+
risk: 'read',
|
|
123
|
+
summary: "Would list the live play session's registered debug commands.",
|
|
124
|
+
},
|
|
125
|
+
async impl(_input, ctx) {
|
|
126
|
+
const transport = getPlayTransport(ctx);
|
|
127
|
+
const session = await resolvePlaySession(ctx, transport);
|
|
128
|
+
const result = await withPlayTimeout(
|
|
129
|
+
transport.listDebugCommands(session, PLAY_READ_TIMEOUT_MS),
|
|
130
|
+
PLAY_READ_TIMEOUT_MS,
|
|
131
|
+
'play.debugCommand.list',
|
|
132
|
+
).catch(() => undefined);
|
|
133
|
+
if (result === undefined) {
|
|
134
|
+
throw new OperationError(
|
|
135
|
+
'DEBUG_COMMANDS_UNSUPPORTED',
|
|
136
|
+
'The connected editor page predates the debug-command relay commands — reload it.',
|
|
137
|
+
{},
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!result.ok) {
|
|
141
|
+
const message = result.error ?? 'list-debug-commands command failed';
|
|
142
|
+
throw new OperationError('COMMAND_FAILED', message, { message });
|
|
143
|
+
}
|
|
144
|
+
const commands =
|
|
145
|
+
(
|
|
146
|
+
result.data as {
|
|
147
|
+
commands?: {
|
|
148
|
+
name: string;
|
|
149
|
+
description?: string;
|
|
150
|
+
argsJsonSchema?: unknown;
|
|
151
|
+
locus: 'client' | 'server';
|
|
152
|
+
}[];
|
|
153
|
+
}
|
|
154
|
+
)?.commands ?? [];
|
|
155
|
+
return { commands };
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// play.debugCommand.invoke
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const PlayDebugCommandInvokeInput = z.object({
|
|
164
|
+
name: z.string().describe('Registered debug-command name to invoke.'),
|
|
165
|
+
args: z
|
|
166
|
+
.array(z.unknown())
|
|
167
|
+
.optional()
|
|
168
|
+
.describe(
|
|
169
|
+
"Positional args, validated against the command's declared Zod tuple by the registry.",
|
|
170
|
+
),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const PlayDebugCommandInvokeResult = z.object({
|
|
174
|
+
result: z.unknown().describe("The command's return value (JSON-serializable), when any."),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export const playDebugCommandInvoke = defineOperation({
|
|
178
|
+
name: 'play.debugCommand.invoke',
|
|
179
|
+
summary: 'Invoke a game-registered debug command in a running play session.',
|
|
180
|
+
description:
|
|
181
|
+
'Executes a ctx.debug.registerCommand registration in the live editor play session via the ' +
|
|
182
|
+
'invoke-debug-command relay command. Debug commands are FIXTURES, never proofs (D16) — ' +
|
|
183
|
+
'server-locus commands resolve on server ack, so pair them with an observable-state wait ' +
|
|
184
|
+
'for replicated effects. Registry failures map onto DEBUG_COMMAND_NOT_REGISTERED / ' +
|
|
185
|
+
'DEBUG_COMMAND_ARGS_INVALID / DEBUG_COMMAND_FAILED; a stale editor page throws ' +
|
|
186
|
+
'DEBUG_COMMANDS_UNSUPPORTED.',
|
|
187
|
+
input: PlayDebugCommandInvokeInput,
|
|
188
|
+
result: PlayDebugCommandInvokeResult,
|
|
189
|
+
errors: [
|
|
190
|
+
PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
|
|
191
|
+
DEBUG_COMMANDS_UNSUPPORTED_ERROR,
|
|
192
|
+
DEBUG_COMMAND_NOT_REGISTERED_ERROR,
|
|
193
|
+
DEBUG_COMMAND_ARGS_INVALID_ERROR,
|
|
194
|
+
DEBUG_COMMAND_FAILED_ERROR,
|
|
195
|
+
COMMAND_FAILED_ERROR,
|
|
196
|
+
],
|
|
197
|
+
requires: { editor: true, play: true },
|
|
198
|
+
host: 'runtime-page',
|
|
199
|
+
mutates: true,
|
|
200
|
+
supportsDryRun: false,
|
|
201
|
+
permission: {
|
|
202
|
+
risk: 'write',
|
|
203
|
+
summary: 'Would execute a game-registered debug fixture command in the live play session.',
|
|
204
|
+
},
|
|
205
|
+
async impl(input, ctx) {
|
|
206
|
+
const transport = getPlayTransport(ctx);
|
|
207
|
+
const session = await resolvePlaySession(ctx, transport);
|
|
208
|
+
const result = await withPlayTimeout(
|
|
209
|
+
transport.invokeDebugCommand(session, input.name, input.args ?? [], PLAY_COMMAND_TIMEOUT_MS),
|
|
210
|
+
PLAY_COMMAND_TIMEOUT_MS,
|
|
211
|
+
'play.debugCommand.invoke',
|
|
212
|
+
).catch(() => undefined);
|
|
213
|
+
if (result === undefined) {
|
|
214
|
+
throw new OperationError(
|
|
215
|
+
'DEBUG_COMMANDS_UNSUPPORTED',
|
|
216
|
+
'The connected editor page predates the debug-command relay commands — reload it.',
|
|
217
|
+
{},
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (!result.ok) {
|
|
221
|
+
const marker = relayErrorMarker(result);
|
|
222
|
+
const message = result.error ?? 'invoke-debug-command command failed';
|
|
223
|
+
switch (marker?.code) {
|
|
224
|
+
case 'DEBUG_COMMAND_NOT_REGISTERED':
|
|
225
|
+
throw new OperationError('DEBUG_COMMAND_NOT_REGISTERED', message, {
|
|
226
|
+
registered: relayStringArray(marker.data['registered']),
|
|
227
|
+
});
|
|
228
|
+
case 'DEBUG_COMMAND_ARGS_INVALID':
|
|
229
|
+
throw new OperationError('DEBUG_COMMAND_ARGS_INVALID', message, {
|
|
230
|
+
issues: Array.isArray(marker.data['issues']) ? marker.data['issues'] : [],
|
|
231
|
+
});
|
|
232
|
+
case 'DEBUG_COMMAND_FAILED':
|
|
233
|
+
throw new OperationError('DEBUG_COMMAND_FAILED', message, marker.data);
|
|
234
|
+
default:
|
|
235
|
+
throw new OperationError('COMMAND_FAILED', message, { message });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { result: (result.data as { result?: unknown } | undefined)?.result };
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
export function registerDebugCommandOperations(registry: OperationRegistry): void {
|
|
243
|
+
registry.register(playDebugCommandList);
|
|
244
|
+
registry.register(playDebugCommandInvoke);
|
|
245
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B4 — `play.*` operations
|
|
3
|
+
* (docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §8 B4). Registered
|
|
4
|
+
* separately from B1's `registerBuiltinOperations` (`../operations.ts`),
|
|
5
|
+
* B2's `registerProjectOperations` (`../project/index.ts`), and B3's
|
|
6
|
+
* `registerEditorOperations` (`../editor/index.ts`) — the default
|
|
7
|
+
* `operations` singleton (`../index.ts`) calls all four.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from './control-operations.js';
|
|
11
|
+
export * from './debug-command-operations.js';
|
|
12
|
+
export * from './input-operations.js';
|
|
13
|
+
export * from './lifecycle-operations.js';
|
|
14
|
+
export * from './log-operations.js';
|
|
15
|
+
export * from './run-ticks-operations.js';
|
|
16
|
+
export * from './state-operations.js';
|
|
17
|
+
export * from './status-operations.js';
|
|
18
|
+
export type {
|
|
19
|
+
ActiveWorldInfo,
|
|
20
|
+
InputInjectionKind,
|
|
21
|
+
InputInjectionRequest,
|
|
22
|
+
PlayCommandResult,
|
|
23
|
+
PlayLogFollowMetadata,
|
|
24
|
+
PlaySessionInfo,
|
|
25
|
+
PlayState,
|
|
26
|
+
PlayStatus,
|
|
27
|
+
PlayTransport,
|
|
28
|
+
RawPlayLogEntry,
|
|
29
|
+
} from './transport.js';
|
|
30
|
+
export {
|
|
31
|
+
getPlayTransport,
|
|
32
|
+
HttpPlayTransport,
|
|
33
|
+
PLAY_COMMAND_TIMEOUT_MS,
|
|
34
|
+
PLAY_PROBE_TIMEOUT_MS,
|
|
35
|
+
PLAY_READ_TIMEOUT_MS,
|
|
36
|
+
PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
|
|
37
|
+
PLAY_SESSION_DISCOVERY_TIMEOUT_MS,
|
|
38
|
+
PLAY_START_TIMEOUT_MS,
|
|
39
|
+
PlayTimeoutError,
|
|
40
|
+
relayErrorMarker,
|
|
41
|
+
relayStringArray,
|
|
42
|
+
resolvePlaySession,
|
|
43
|
+
withPlayTimeout,
|
|
44
|
+
} from './transport.js';
|
|
45
|
+
|
|
46
|
+
import type { OperationRegistry } from '../registry.js';
|
|
47
|
+
import { registerControlOperations } from './control-operations.js';
|
|
48
|
+
import { registerDebugCommandOperations } from './debug-command-operations.js';
|
|
49
|
+
import { registerInputOperations } from './input-operations.js';
|
|
50
|
+
import { registerLifecycleOperations } from './lifecycle-operations.js';
|
|
51
|
+
import { registerLogOperations } from './log-operations.js';
|
|
52
|
+
import { registerRunTicksOperations } from './run-ticks-operations.js';
|
|
53
|
+
import { registerStateOperations } from './state-operations.js';
|
|
54
|
+
import { registerStatusOperations } from './status-operations.js';
|
|
55
|
+
|
|
56
|
+
/** Register every B4 `play.*` operation onto `registry`. */
|
|
57
|
+
export function registerPlayOperations(registry: OperationRegistry): void {
|
|
58
|
+
registerLifecycleOperations(registry);
|
|
59
|
+
registerStatusOperations(registry);
|
|
60
|
+
registerLogOperations(registry);
|
|
61
|
+
registerInputOperations(registry);
|
|
62
|
+
registerControlOperations(registry);
|
|
63
|
+
registerRunTicksOperations(registry);
|
|
64
|
+
registerStateOperations(registry);
|
|
65
|
+
registerDebugCommandOperations(registry);
|
|
66
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `play.input.inject` (B4, §8 B4 "input injection through normal input
|
|
3
|
+
* paths"; wired for real in Wave 5, docs/SYNTHETIC-PLAYER-SPEC.md §3.2/§3.5).
|
|
4
|
+
*
|
|
5
|
+
* MUST exercise the NORMAL input action path, never a direct game-state
|
|
6
|
+
* mutation (§8 B4 AC). Two tiers, both engine-native:
|
|
7
|
+
*
|
|
8
|
+
* - `kind: 'action'` (PRIMARY) — `InputManager.setVirtualAction(action,
|
|
9
|
+
* value)`: OR'd/summed into the same action reads humans drive, gated by
|
|
10
|
+
* the SAME `inputActive` (focus/enabled) gate as human input. A gated
|
|
11
|
+
* actuation surfaces `{delivered: false, reason}` and this op throws the
|
|
12
|
+
* declared `INPUT_GATED` — never a false ok. Unknown action names throw
|
|
13
|
+
* `INPUT_ACTION_NOT_FOUND` carrying the declared-action list. `atTick`
|
|
14
|
+
* (D15/T-D15.5, docs/D15-DETERMINISM-DESIGN.md §2.c) defers the actuation
|
|
15
|
+
* to `InputManager.scheduleActionAtTick` instead: applied at the START of
|
|
16
|
+
* that tick's input phase, composing with `play.runTicks`/
|
|
17
|
+
* `window.__vgai.runTicks` (schedule at tick 500, run 1000 ticks, the
|
|
18
|
+
* action fires exactly at 500 — a digital `true` produces a genuine
|
|
19
|
+
* `isJustPressed` edge exactly at that tick, not merely a held value). A
|
|
20
|
+
* past tick throws the declared `TICK_ALREADY_PASSED` (`data.currentTick`,
|
|
21
|
+
* the NEXT tick the target session will service); the result carries
|
|
22
|
+
* `scheduled: true` instead of `delivered` (there is no gate outcome to
|
|
23
|
+
* report yet — the actuation hasn't happened). CAVEAT: a world that is
|
|
24
|
+
* paused/frozen when its target tick would have been serviced (or is
|
|
25
|
+
* skipped by a multi-tick gap) silently drops the schedule rather than
|
|
26
|
+
* applying it late — the only trace is an `'input.schedule.dropped'`
|
|
27
|
+
* debug event (`{tick, action}`), readable via `play.gameplayState
|
|
28
|
+
* .inspect`/`play.debugCommand`'s event surface, not through this op.
|
|
29
|
+
* - The four legacy named-test-source kinds (`'axis' | 'vector2' |
|
|
30
|
+
* 'pointerDelta' | 'pointerPosition'`) — `InputManager.injectAxis`/
|
|
31
|
+
* `injectVector2`/`injectPointerDelta`/`injectPointerPosition`: push a
|
|
32
|
+
* raw value into a NAMED source, read back only by an action bound to a
|
|
33
|
+
* `test_*` binding in the project's `.inputmap.json` — the same
|
|
34
|
+
* deadzone/normalize/combine aggregation path real devices go through.
|
|
35
|
+
* `atTick` does not apply to these (no schedule primitive exists for a
|
|
36
|
+
* raw named source).
|
|
37
|
+
*
|
|
38
|
+
* Either way there is structurally no way to express "set entity X's field
|
|
39
|
+
* Y" through this op — an injected value only affects gameplay if a declared
|
|
40
|
+
* action reads it, exactly like a real device.
|
|
41
|
+
*
|
|
42
|
+
* `worldId` (D15/T-D15.5, all kinds — the closed-PR review's objection-2
|
|
43
|
+
* fix): targets a SPECIFIC world's `InputManager` in a multi-world project.
|
|
44
|
+
* Omitted, it resolves to the SAME default world `window.__vgai.input.*`
|
|
45
|
+
* (door a) resolves to — one shared resolution function
|
|
46
|
+
* (`debug-registry.ts`'s `resolveInputWorldId`), so this op and the debug
|
|
47
|
+
* bridge can never disagree about which world an unqualified injection
|
|
48
|
+
* targets. An explicit, unregistered `worldId` throws the declared
|
|
49
|
+
* `INPUT_WORLD_NOT_FOUND` (`data.registered` lists every world with a wired
|
|
50
|
+
* `InputManager`).
|
|
51
|
+
*
|
|
52
|
+
* Wire: relay case `inject-input` (`command-listener.ts`) reaching the live
|
|
53
|
+
* session's per-world `InputManager` (via `play-mode.ts`'s
|
|
54
|
+
* `getPlayRuntimeAccess().getInputTarget(worldId?)`, itself backed by
|
|
55
|
+
* `DebugRegistry.getVirtualInputTarget`). Against a stale editor page the
|
|
56
|
+
* transport translates the structured `UNKNOWN_COMMAND_TYPE` marker into
|
|
57
|
+
* `undefined` and this op throws the declared `INPUT_INJECTION_UNSUPPORTED`.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { z } from 'zod';
|
|
61
|
+
import { OperationError } from '../errors.js';
|
|
62
|
+
import { defineOperation, type OperationRegistry } from '../registry.js';
|
|
63
|
+
import type { InputInjectionRequest, PlayCommandResult } from './transport.js';
|
|
64
|
+
import {
|
|
65
|
+
getPlayTransport,
|
|
66
|
+
PLAY_COMMAND_TIMEOUT_MS,
|
|
67
|
+
PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
|
|
68
|
+
relayErrorMarker,
|
|
69
|
+
relayStringArray,
|
|
70
|
+
resolvePlaySession,
|
|
71
|
+
withPlayTimeout,
|
|
72
|
+
} from './transport.js';
|
|
73
|
+
|
|
74
|
+
const INPUT_INJECTION_UNSUPPORTED_ERROR = {
|
|
75
|
+
code: 'INPUT_INJECTION_UNSUPPORTED',
|
|
76
|
+
summary:
|
|
77
|
+
'The connected editor page predates the inject-input relay command (version skew, matched ' +
|
|
78
|
+
'on the structured UNKNOWN_COMMAND_TYPE marker) — reload the editor tab. Never silently ' +
|
|
79
|
+
'reported as success.',
|
|
80
|
+
data: z.object({}),
|
|
81
|
+
} as const;
|
|
82
|
+
|
|
83
|
+
const INPUT_ACTION_NOT_FOUND_ERROR = {
|
|
84
|
+
code: 'INPUT_ACTION_NOT_FOUND',
|
|
85
|
+
summary:
|
|
86
|
+
'An action-level injection named an action the game never declared. `data.registered` ' +
|
|
87
|
+
'lists every declared action name.',
|
|
88
|
+
data: z.object({ registered: z.array(z.string()) }),
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
const INPUT_GATED_ERROR = {
|
|
92
|
+
code: 'INPUT_GATED',
|
|
93
|
+
summary:
|
|
94
|
+
'The injection reached the live InputManager but input is gated (page unfocused, Game tab ' +
|
|
95
|
+
'inactive, or input disabled) — nothing was delivered. The honest {delivered:false} ' +
|
|
96
|
+
'surface, never a false ok.',
|
|
97
|
+
data: z.object({ reason: z.string() }),
|
|
98
|
+
} as const;
|
|
99
|
+
|
|
100
|
+
const TICK_ALREADY_PASSED_ERROR = {
|
|
101
|
+
code: 'TICK_ALREADY_PASSED',
|
|
102
|
+
summary:
|
|
103
|
+
'atTick named a tick the running session already elapsed — scheduling only applies to the ' +
|
|
104
|
+
'current or a future tick. data.currentTick carries the NEXT tick the session will service.',
|
|
105
|
+
data: z.object({ currentTick: z.number() }),
|
|
106
|
+
} as const;
|
|
107
|
+
|
|
108
|
+
const INPUT_WORLD_NOT_FOUND_ERROR = {
|
|
109
|
+
code: 'INPUT_WORLD_NOT_FOUND',
|
|
110
|
+
summary:
|
|
111
|
+
'An explicit worldId named a world with no wired InputManager. data.registered lists every ' +
|
|
112
|
+
'world id that has one.',
|
|
113
|
+
data: z.object({ worldId: z.string(), registered: z.array(z.string()) }),
|
|
114
|
+
} as const;
|
|
115
|
+
|
|
116
|
+
const COMMAND_FAILED_ERROR = {
|
|
117
|
+
code: 'COMMAND_FAILED',
|
|
118
|
+
summary: 'The connected editor rejected or failed to execute the relayed injection command.',
|
|
119
|
+
data: z.object({ message: z.string() }),
|
|
120
|
+
} as const;
|
|
121
|
+
|
|
122
|
+
const Vector2Schema = z.object({ x: z.number(), y: z.number() });
|
|
123
|
+
|
|
124
|
+
const WorldIdField = z
|
|
125
|
+
.string()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe(
|
|
128
|
+
'D15/T-D15.5 — target a SPECIFIC world (multi-world projects). Omitted resolves to the ' +
|
|
129
|
+
'same default world window.__vgai.input.* resolves to (one shared resolution function) — ' +
|
|
130
|
+
'never "whichever world happened to mount last". Throws INPUT_WORLD_NOT_FOUND for an ' +
|
|
131
|
+
'explicit, unregistered id.',
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const PlayInputInjectInput = z
|
|
135
|
+
.discriminatedUnion('kind', [
|
|
136
|
+
z.object({
|
|
137
|
+
kind: z.literal('action'),
|
|
138
|
+
action: z
|
|
139
|
+
.string()
|
|
140
|
+
.describe(
|
|
141
|
+
'Declared input-action name to drive at the action level ' +
|
|
142
|
+
'(InputManager.setVirtualAction) — the honest, focus-gated path.',
|
|
143
|
+
),
|
|
144
|
+
value: z
|
|
145
|
+
.union([z.boolean(), z.number(), Vector2Schema])
|
|
146
|
+
.describe(
|
|
147
|
+
"Value matching the action's declared valueType: boolean holds/releases a digital " +
|
|
148
|
+
'action, number a scalar, {x,y} a vector2.',
|
|
149
|
+
),
|
|
150
|
+
atTick: z
|
|
151
|
+
.number()
|
|
152
|
+
.int()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe(
|
|
155
|
+
"D15/T-D15.5 — if set, defers the actuation to the START of that tick's input phase " +
|
|
156
|
+
'(InputManager.scheduleActionAtTick) instead of applying it immediately; composes ' +
|
|
157
|
+
'with play.runTicks/window.__vgai.runTicks. Throws TICK_ALREADY_PASSED for a tick ' +
|
|
158
|
+
'that already elapsed in the running session.',
|
|
159
|
+
),
|
|
160
|
+
worldId: WorldIdField,
|
|
161
|
+
}),
|
|
162
|
+
z.object({
|
|
163
|
+
kind: z.literal('axis'),
|
|
164
|
+
sourceId: z.string().describe('Named test-input source id an action binds to (test_axis).'),
|
|
165
|
+
value: z.number().describe('Raw scalar value (same units InputManager.injectAxis takes).'),
|
|
166
|
+
worldId: WorldIdField,
|
|
167
|
+
}),
|
|
168
|
+
z.object({
|
|
169
|
+
kind: z.literal('vector2'),
|
|
170
|
+
sourceId: z
|
|
171
|
+
.string()
|
|
172
|
+
.describe('Named test-input source id an action binds to (test_vector2).'),
|
|
173
|
+
value: Vector2Schema,
|
|
174
|
+
worldId: WorldIdField,
|
|
175
|
+
}),
|
|
176
|
+
z.object({
|
|
177
|
+
kind: z.literal('pointerDelta'),
|
|
178
|
+
sourceId: z
|
|
179
|
+
.string()
|
|
180
|
+
.describe('Named test-input source id an action binds to (test_pointer_delta).'),
|
|
181
|
+
value: Vector2Schema,
|
|
182
|
+
worldId: WorldIdField,
|
|
183
|
+
}),
|
|
184
|
+
z.object({
|
|
185
|
+
kind: z.literal('pointerPosition'),
|
|
186
|
+
sourceId: z
|
|
187
|
+
.string()
|
|
188
|
+
.describe('Named test-input source id an action binds to (test_pointer_position).'),
|
|
189
|
+
value: Vector2Schema,
|
|
190
|
+
worldId: WorldIdField,
|
|
191
|
+
}),
|
|
192
|
+
])
|
|
193
|
+
.describe(
|
|
194
|
+
'Action-level virtual input (setVirtualAction — primary, optionally scheduled via atTick) ' +
|
|
195
|
+
'plus the four named-test-source shapes InputManager exposes (injectAxis/injectVector2/' +
|
|
196
|
+
'injectPointerDelta/injectPointerPosition) — never an arbitrary game-state write. worldId ' +
|
|
197
|
+
'targets a specific world in a multi-world project.',
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
/** Maps a relayed inject-input failure's structured marker onto this op's
|
|
201
|
+
* declared error codes — extracted from `impl` below purely to keep that
|
|
202
|
+
* function's cognitive complexity under the repo's lint ceiling; the
|
|
203
|
+
* mapping itself is a plain lookup with no independent behavior. */
|
|
204
|
+
function throwForInjectFailure(result: PlayCommandResult): never {
|
|
205
|
+
const marker = relayErrorMarker(result);
|
|
206
|
+
const message = result.error ?? 'inject-input command failed';
|
|
207
|
+
if (marker?.code === 'INPUT_ACTION_NOT_FOUND') {
|
|
208
|
+
throw new OperationError('INPUT_ACTION_NOT_FOUND', message, {
|
|
209
|
+
registered: relayStringArray(marker.data['registered']),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (marker?.code === 'TICK_ALREADY_PASSED') {
|
|
213
|
+
throw new OperationError('TICK_ALREADY_PASSED', message, {
|
|
214
|
+
currentTick: typeof marker.data['currentTick'] === 'number' ? marker.data['currentTick'] : 0,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (marker?.code === 'DEBUG_INPUT_WORLD_NOT_FOUND') {
|
|
218
|
+
throw new OperationError('INPUT_WORLD_NOT_FOUND', message, {
|
|
219
|
+
worldId: typeof marker.data['worldId'] === 'string' ? marker.data['worldId'] : '',
|
|
220
|
+
registered: relayStringArray(marker.data['registered']),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
throw new OperationError('COMMAND_FAILED', message, { message });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const PlayInputInjectResult = z.object({
|
|
227
|
+
ok: z.literal(true),
|
|
228
|
+
scheduled: z
|
|
229
|
+
.boolean()
|
|
230
|
+
.optional()
|
|
231
|
+
.describe(
|
|
232
|
+
'D15/T-D15.5 — true when atTick deferred the actuation to a future tick instead of ' +
|
|
233
|
+
'applying it immediately (omitted for an immediate actuation).',
|
|
234
|
+
),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
export const playInputInject = defineOperation({
|
|
238
|
+
name: 'play.input.inject',
|
|
239
|
+
summary: 'Inject synthetic input into a running play session through the normal action path.',
|
|
240
|
+
description:
|
|
241
|
+
'Drives a declared action at the action level (setVirtualAction — gated identically to ' +
|
|
242
|
+
'human input; a gated call throws INPUT_GATED, never a false ok — or, with atTick, ' +
|
|
243
|
+
'scheduleActionAtTick, deferred to a future tick) or pushes a raw value into a named ' +
|
|
244
|
+
'InputManager test source (injectAxis family, read back only by a declared test_* ' +
|
|
245
|
+
'binding). worldId targets a specific world in a multi-world project. Never mutates game ' +
|
|
246
|
+
'state directly. Throws INPUT_INJECTION_UNSUPPORTED against a stale editor page.',
|
|
247
|
+
input: PlayInputInjectInput,
|
|
248
|
+
result: PlayInputInjectResult,
|
|
249
|
+
errors: [
|
|
250
|
+
PLAY_RUNTIME_NOT_AVAILABLE_ERROR,
|
|
251
|
+
INPUT_INJECTION_UNSUPPORTED_ERROR,
|
|
252
|
+
INPUT_ACTION_NOT_FOUND_ERROR,
|
|
253
|
+
INPUT_GATED_ERROR,
|
|
254
|
+
TICK_ALREADY_PASSED_ERROR,
|
|
255
|
+
INPUT_WORLD_NOT_FOUND_ERROR,
|
|
256
|
+
COMMAND_FAILED_ERROR,
|
|
257
|
+
],
|
|
258
|
+
requires: { editor: true, play: true },
|
|
259
|
+
host: 'runtime-page',
|
|
260
|
+
mutates: true,
|
|
261
|
+
supportsDryRun: false,
|
|
262
|
+
permission: {
|
|
263
|
+
risk: 'write',
|
|
264
|
+
summary:
|
|
265
|
+
'Feeds a value into one declared action or named synthetic input source of the live ' +
|
|
266
|
+
'play session only.',
|
|
267
|
+
},
|
|
268
|
+
async impl(input, ctx) {
|
|
269
|
+
const transport = getPlayTransport(ctx);
|
|
270
|
+
const session = await resolvePlaySession(ctx, transport);
|
|
271
|
+
const request: InputInjectionRequest =
|
|
272
|
+
input.kind === 'action'
|
|
273
|
+
? {
|
|
274
|
+
kind: 'action',
|
|
275
|
+
action: input.action,
|
|
276
|
+
value: input.value,
|
|
277
|
+
...(input.atTick !== undefined ? { atTick: input.atTick } : {}),
|
|
278
|
+
...(input.worldId !== undefined ? { worldId: input.worldId } : {}),
|
|
279
|
+
}
|
|
280
|
+
: {
|
|
281
|
+
kind: input.kind,
|
|
282
|
+
sourceId: input.sourceId,
|
|
283
|
+
value: input.value,
|
|
284
|
+
...(input.worldId !== undefined ? { worldId: input.worldId } : {}),
|
|
285
|
+
};
|
|
286
|
+
const result = await withPlayTimeout(
|
|
287
|
+
transport.injectInput(session, request, PLAY_COMMAND_TIMEOUT_MS),
|
|
288
|
+
PLAY_COMMAND_TIMEOUT_MS,
|
|
289
|
+
'play.input.inject',
|
|
290
|
+
).catch(() => undefined);
|
|
291
|
+
if (!result) {
|
|
292
|
+
throw new OperationError(
|
|
293
|
+
'INPUT_INJECTION_UNSUPPORTED',
|
|
294
|
+
'The connected editor page predates the inject-input relay command — reload it.',
|
|
295
|
+
{},
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (!result.ok) {
|
|
299
|
+
throwForInjectFailure(result);
|
|
300
|
+
}
|
|
301
|
+
const outcome = result.data as
|
|
302
|
+
| { delivered?: boolean; reason?: string; scheduled?: boolean }
|
|
303
|
+
| undefined;
|
|
304
|
+
if (outcome?.delivered === false) {
|
|
305
|
+
const reason = outcome.reason ?? 'input-gated';
|
|
306
|
+
throw new OperationError('INPUT_GATED', reason, { reason });
|
|
307
|
+
}
|
|
308
|
+
return outcome?.scheduled === true
|
|
309
|
+
? { ok: true as const, scheduled: true }
|
|
310
|
+
: { ok: true as const };
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
export function registerInputOperations(registry: OperationRegistry): void {
|
|
315
|
+
registry.register(playInputInject);
|
|
316
|
+
}
|