@vgai/live 0.5.2 → 0.5.4

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 (62) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/editor.d.ts +78 -55
  3. package/dist/editor.js +129 -76
  4. package/dist/game-client/bridge-heartbeat.d.ts +49 -0
  5. package/dist/game-client/bridge-heartbeat.js +46 -0
  6. package/dist/game-client/bridge-transport.d.ts +75 -0
  7. package/dist/game-client/bridge-transport.js +19 -0
  8. package/dist/game-client/client.d.ts +293 -0
  9. package/dist/game-client/client.js +706 -0
  10. package/dist/game-client/errors.d.ts +57 -0
  11. package/dist/game-client/errors.js +76 -0
  12. package/dist/game-client/events-matcher.d.ts +41 -0
  13. package/dist/game-client/events-matcher.js +68 -0
  14. package/dist/game-client/failure-block.d.ts +93 -0
  15. package/dist/game-client/failure-block.js +97 -0
  16. package/dist/game-client/fast-forward.d.ts +125 -0
  17. package/dist/game-client/fast-forward.js +122 -0
  18. package/dist/game-client/hidden-recovery.d.ts +85 -0
  19. package/dist/game-client/hidden-recovery.js +105 -0
  20. package/dist/game-client/index.d.ts +40 -0
  21. package/dist/game-client/index.js +26 -0
  22. package/dist/game-client/perf-sampling.d.ts +56 -0
  23. package/dist/game-client/perf-sampling.js +85 -0
  24. package/dist/game-client/relay-transport.d.ts +100 -0
  25. package/dist/game-client/relay-transport.js +237 -0
  26. package/dist/game-client/screenshot-target.d.ts +60 -0
  27. package/dist/game-client/screenshot-target.js +68 -0
  28. package/dist/game-client/state-cap.d.ts +7 -0
  29. package/dist/game-client/state-cap.js +21 -0
  30. package/dist/game-client/types.d.ts +128 -0
  31. package/dist/game-client/types.js +15 -0
  32. package/dist/game-client/wait-for.d.ts +155 -0
  33. package/dist/game-client/wait-for.js +229 -0
  34. package/dist/game.d.ts +47 -18
  35. package/dist/game.js +59 -16
  36. package/dist/index.d.ts +46 -21
  37. package/dist/index.js +51 -20
  38. package/dist/session.d.ts +4 -4
  39. package/dist/session.js +7 -7
  40. package/dist/tools.d.ts +12 -3
  41. package/dist/tools.js +15 -6
  42. package/package.json +10 -5
  43. package/src/editor.ts +142 -96
  44. package/src/game-client/bridge-heartbeat.ts +61 -0
  45. package/src/game-client/bridge-transport.ts +73 -0
  46. package/src/game-client/client.ts +836 -0
  47. package/src/game-client/errors.ts +96 -0
  48. package/src/game-client/events-matcher.ts +106 -0
  49. package/src/game-client/failure-block.ts +199 -0
  50. package/src/game-client/fast-forward.ts +175 -0
  51. package/src/game-client/hidden-recovery.ts +149 -0
  52. package/src/game-client/index.ts +98 -0
  53. package/src/game-client/perf-sampling.ts +94 -0
  54. package/src/game-client/relay-transport.ts +311 -0
  55. package/src/game-client/screenshot-target.ts +91 -0
  56. package/src/game-client/state-cap.ts +29 -0
  57. package/src/game-client/types.ts +137 -0
  58. package/src/game-client/wait-for.ts +327 -0
  59. package/src/game.ts +96 -16
  60. package/src/index.ts +68 -31
  61. package/src/session.ts +8 -10
  62. package/src/tools.ts +19 -6
@@ -0,0 +1,836 @@
1
+ /**
2
+ * The Playwright-page-bound wiring for `game`'s client methods (D17: specs
3
+ * drive `window.__vgai` via `page.evaluate` — the standalone page has no HTTP
4
+ * channel). This module is deliberately the ONLY place that touches `Page`;
5
+ * the budget math (`wait-for.ts`), the events matcher (`events-matcher.ts`),
6
+ * and the failure-block assembler (`failure-block.ts`) are plain modules
7
+ * that never import Playwright, per Task 3.2's architecture requirement.
8
+ */
9
+
10
+ import { mkdir } from 'node:fs/promises';
11
+ import { dirname, resolve } from 'node:path';
12
+ import type { Page } from '@playwright/test';
13
+ import { type BridgeHeartbeatState, maybeBridgeHeartbeat } from './bridge-heartbeat.js';
14
+ import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
15
+ import { appendWarmSessionHint, inputGatedError, SessionError, SessionFailure } from './errors.js';
16
+ import { describeEventsExpectation, matchEventsSubsequence } from './events-matcher.js';
17
+ import { assembleFailureBlock } from './failure-block.js';
18
+ import {
19
+ type FastForwardBudget,
20
+ type FastForwardClock,
21
+ type FastForwardOptions,
22
+ runFastForward,
23
+ } from './fast-forward.js';
24
+ import { HiddenRecoveryDriver } from './hidden-recovery.js';
25
+ import { TpsAccumulator, type TpsStats } from './perf-sampling.js';
26
+ import { resolveScreenshotTarget } from './screenshot-target.js';
27
+ import type {
28
+ DebugCommandInfo,
29
+ DebugSnapshot,
30
+ ProviderInfo,
31
+ VirtualActionResult,
32
+ VirtualActionValue,
33
+ } from './types.js';
34
+ import {
35
+ assertValidWaitForBudget,
36
+ type HeartbeatState,
37
+ maybeHeartbeat,
38
+ runWaitFor,
39
+ WAIT_FOR_STALL_POLL_LIMIT,
40
+ type WaitForBudget,
41
+ WaitForTimeoutError,
42
+ } from './wait-for.js';
43
+
44
+ /** Runs inside the page. Kept as a single exported plain function (not a
45
+ * closure over Node state) because `page.evaluate(fn, arg)` only ships
46
+ * `fn`'s own source across the boundary. */
47
+ function bridgeCallInPage(args: { method: string; callArgs: unknown[] }): BridgeCallOutcome {
48
+ const bridge = (window as unknown as { __vgai?: Record<string, unknown> }).__vgai;
49
+ if (!bridge) {
50
+ return {
51
+ ok: false,
52
+ error: { code: undefined, message: 'window.__vgai is not installed on this page' },
53
+ };
54
+ }
55
+ const parts = args.method.split('.');
56
+ let parent: Record<string, unknown> = bridge;
57
+ for (let i = 0; i < parts.length - 1; i++) {
58
+ parent = parent[parts[i] as string] as Record<string, unknown>;
59
+ }
60
+ const key = parts[parts.length - 1] as string;
61
+ const fn = parent[key] as (...fnArgs: unknown[]) => unknown;
62
+ try {
63
+ const result = fn.apply(parent, args.callArgs);
64
+ return { ok: true, result };
65
+ } catch (err) {
66
+ const e = err as { code?: string; message?: string; data?: unknown };
67
+ return {
68
+ ok: false,
69
+ error: { code: e?.code, message: e?.message ?? String(err), data: e?.data },
70
+ };
71
+ }
72
+ }
73
+
74
+ /** Same shape, but awaits the call — used for `invoke` (async debug
75
+ * commands). */
76
+ async function bridgeCallInPageAsync(args: {
77
+ method: string;
78
+ callArgs: unknown[];
79
+ }): Promise<BridgeCallOutcome> {
80
+ const bridge = (window as unknown as { __vgai?: Record<string, unknown> }).__vgai;
81
+ if (!bridge) {
82
+ return {
83
+ ok: false,
84
+ error: { code: undefined, message: 'window.__vgai is not installed on this page' },
85
+ };
86
+ }
87
+ const parts = args.method.split('.');
88
+ let parent: Record<string, unknown> = bridge;
89
+ for (let i = 0; i < parts.length - 1; i++) {
90
+ parent = parent[parts[i] as string] as Record<string, unknown>;
91
+ }
92
+ const key = parts[parts.length - 1] as string;
93
+ const fn = parent[key] as (...fnArgs: unknown[]) => unknown;
94
+ try {
95
+ const result = await fn.apply(parent, args.callArgs);
96
+ return { ok: true, result };
97
+ } catch (err) {
98
+ const e = err as { code?: string; message?: string; data?: unknown };
99
+ return {
100
+ ok: false,
101
+ error: { code: e?.code, message: e?.message ?? String(err), data: e?.data },
102
+ };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * #140 — the `BridgeTransport` `page.evaluate` implementation. This is the
108
+ * ONE place a `Page` is ever touched to drive `window.__vgai` (module doc
109
+ * above); `RelayTransport` (`relay-transport.ts`) is the sibling
110
+ * implementation for the live editor session, and imports no Playwright at
111
+ * all. Exported so `fixture.ts` (which already imports `@playwright/test`
112
+ * for its own `page.goto`/error-listener setup) can construct one.
113
+ */
114
+ export class PageTransport implements BridgeTransport {
115
+ constructor(private readonly page: Page) {}
116
+
117
+ async call(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome> {
118
+ return this.page.evaluate(bridgeCallInPage, { method, callArgs });
119
+ }
120
+
121
+ async callAsync(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome> {
122
+ return this.page.evaluate(bridgeCallInPageAsync, { method, callArgs });
123
+ }
124
+
125
+ async isHidden(): Promise<boolean> {
126
+ return this.page.evaluate(() => document.hidden);
127
+ }
128
+
129
+ async bringToFront(): Promise<void> {
130
+ await this.page.bringToFront();
131
+ }
132
+
133
+ async screenshot(path: string): Promise<void> {
134
+ await mkdir(dirname(path), { recursive: true });
135
+ await this.page.screenshot({ path });
136
+ }
137
+
138
+ /** Wave-2: the ONE transport that runs a `game.page()` step against a REAL
139
+ * Playwright `Page` — no serialization, so `step`'s own closures work
140
+ * here (see `bridge-transport.ts`'s `runPageScript` doc comment for the
141
+ * full honesty-boundary contract; `src` is unused on this leg, kept only
142
+ * to satisfy the shared interface). */
143
+ async runPageScript(_src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome> {
144
+ try {
145
+ const result = await step(this.page);
146
+ return { ok: true, result };
147
+ } catch (err) {
148
+ return {
149
+ ok: false,
150
+ error: { code: undefined, message: err instanceof Error ? err.message : String(err) },
151
+ };
152
+ }
153
+ }
154
+ }
155
+
156
+ export interface GameClientOptions {
157
+ /** The bridge transport — `new PageTransport(page)` for a standalone page
158
+ * (`fixture.ts`), `new RelayTransport({ port })` for `--in-editor`
159
+ * (`relay-fixture.ts`). See `bridge-transport.ts`'s module doc: every
160
+ * method below reaches `window.__vgai` (or its relay-side equivalent)
161
+ * ONLY through this seam. */
162
+ transport: BridgeTransport;
163
+ /** Populated by the fixture's `pageerror` listener, installed before goto. */
164
+ pageErrors: string[];
165
+ /** Populated by the fixture's `console` listener (type `'error'`), same timing. */
166
+ consoleErrors: string[];
167
+ /** First snapshot's `time.tick` — used only for `events.expect`'s
168
+ * `withinTicks` timing math (`events-matcher.ts`) and the D5 failure-block
169
+ * deltas below; identity fencing itself is `fenceSeq`'s job (run-4
170
+ * friction #5 — see that field's doc comment). */
171
+ fenceTick: number;
172
+ /** Run-4 friction #5: the fence snapshot's own event ring's last `seq`
173
+ * (`0` if the ring was empty at fence time — nothing emitted yet, so
174
+ * "since seq 0" is correct). `events.expect` fences on THIS, not
175
+ * `fenceTick` — the removed `tick > sinceTick` filter silently dropped any event that
176
+ * shares the fence's own tick (e.g. one emitted from a debug-command
177
+ * handler, which runs between ticks), which is exactly the bug this field
178
+ * closes. See `debug-registry.ts`'s `TickStampedEvent.seq` doc comment
179
+ * for the full mechanism. */
180
+ fenceSeq: number;
181
+ /** D5: first snapshot's `time.simSeconds` — `events.expect`'s failure block
182
+ * computes REAL elapsed sim-time as a delta off this, instead of the
183
+ * absolute (since-boot) `simSeconds` the pre-fix code passed. */
184
+ fenceSimSeconds: number;
185
+ /** D5: `Date.now()` at the same moment as the fence snapshot — the wall
186
+ * side of that same delta, instead of the pre-fix code's hardcoded `0`
187
+ * (which fabricated a "sim speed 0.00x — loop stalled" reading on every
188
+ * single `events.expect` failure, regardless of how the game was
189
+ * actually running). */
190
+ fenceWallMs: number;
191
+ artifactsDir?: string | undefined;
192
+ /** Set by the caller when it reused an already-running game server rather
193
+ * than booting a fresh one for this run. Threaded through so `unwrap` can
194
+ * append the warm-session staleness hint to a
195
+ * `DEBUG_COMMAND_NOT_REGISTERED` failure (see `errors.ts`'s
196
+ * `appendWarmSessionHint`). Defaults to `false` so every caller that never
197
+ * sets it is unaffected. */
198
+ warmSession?: boolean;
199
+ /** `testInfo.title` — names the fixture heartbeat lines `waitFor`/
200
+ * `waitSimTime` print during a long poll (see `wait-for.ts`'s module doc).
201
+ * Defaults to `'test'` so a caller that never sets it (existing direct
202
+ * `GameClient` construction in older tests) still gets a valid, if
203
+ * generic, heartbeat line rather than `undefined` in the output. */
204
+ testTitle?: string;
205
+ }
206
+
207
+ /**
208
+ * A relay-transport call that overran its async-invoke timeout budget
209
+ * surfaces as a `RELAY_UNREACHABLE` `SessionError` whose message names the
210
+ * timeout (`relay-transport.ts`'s `postCommand` uses `AbortSignal.timeout`,
211
+ * and Node's fetch rejects an aborted-by-timeout signal with a message
212
+ * containing "timeout") — a genuine network-down failure gets the SAME code
213
+ * but never mentions timeout, so this narrows on the message text, not just
214
+ * the code, before deciding to append the split-the-hold hint. Appends
215
+ * rather than replaces so the underlying transport message stays visible.
216
+ * `action` is whatever the caller passed to `hold()` (a single name or an
217
+ * array of them) — `JSON.stringify` renders either shape sensibly in the
218
+ * hint.
219
+ */
220
+ function rethrowIfHoldTimedOut(err: unknown, action: string | string[], simSeconds: number): never {
221
+ if (
222
+ err instanceof SessionError &&
223
+ err.code === 'RELAY_UNREACHABLE' &&
224
+ /timeout/i.test(err.message)
225
+ ) {
226
+ throw new SessionError(
227
+ err.code,
228
+ `${err.message}\nhint: game.input.hold(${JSON.stringify(action)}, { simSeconds: ${simSeconds} }) ` +
229
+ "may have exceeded the relay transport's async-invoke timeout budget — split it into " +
230
+ 'multiple shorter game.input.hold() calls instead of one long hold',
231
+ err.data,
232
+ );
233
+ }
234
+ throw err;
235
+ }
236
+
237
+ /** {@link GameInput.tap}'s budget: one fixed tick at the default 60Hz
238
+ * timestep. `waitForHoldBudget` resolves on the FIRST reading that covers
239
+ * the budget, so this asks for the shortest press a tick can actually
240
+ * service — never a wall-clock duration. A project running a non-default
241
+ * fixed timestep simply covers it in its own single (longer) tick. */
242
+ const TAP_SIM_SECONDS = 1 / 60;
243
+
244
+ export class GameInput {
245
+ constructor(private readonly client: GameClient) {}
246
+
247
+ /**
248
+ * Holds one action, or SIMULTANEOUSLY holds several — `hold('jump')` and
249
+ * `hold(['move_right', 'move_forward'])` (the natural way to drive a
250
+ * diagonal) are both first-class. `action` is deliberately typed as
251
+ * `string | string[]`, never just `string`: a bare JS array silently
252
+ * stringifies to a comma-joined name (`['move_right','move_forward']` →
253
+ * `"move_right,move_forward"`) wherever it crosses a template literal or
254
+ * the bridge's JSON boundary, which used to surface as a baffling
255
+ * `unknown action "move_right,move_forward"` — the array was accepted
256
+ * structurally (nothing rejected it) and then silently mangled instead.
257
+ * Accepting the array for real, instead of merely rejecting it, is the
258
+ * useful behavior: every 2D/3D mover needs "hold two directions at once"
259
+ * as its ordinary case, not an edge case.
260
+ *
261
+ * Single action: one `holdFor` bridge call (`runtime/debug-bridge.ts`)
262
+ * instead of the old set → `waitSimTime`'s 150ms-interval snapshot poll
263
+ * loop → clear (15+ transport round trips over the editor relay for a
264
+ * multi-second hold). The wait uses ordinary host-loop ticks while visible.
265
+ * If the browser has hidden-paused that loop, the bridge drives the held
266
+ * action through the same game phases with deterministic ticks; this
267
+ * collapses transport cost without letting a background tab deadlock it.
268
+ *
269
+ * A relay call has a bounded async-invoke timeout budget (see
270
+ * `relay-transport.ts`'s `INVOKE_TIMEOUT_MS`) — a `simSeconds` long enough
271
+ * to exceed it at worst-case sim speed is NOT silently truncated; the
272
+ * transport timeout is caught and rethrown with a hint to split the hold
273
+ * into multiple shorter `hold()` calls instead.
274
+ *
275
+ * Multiple actions: `holdFor` is a single-action bridge primitive, so this
276
+ * presses every action (`setVirtualAction(action, true)`, in order),
277
+ * waits the shared `simSeconds` once via `waitSimTime`, and releases every
278
+ * action it managed to press — in a `finally`, so a gated press (a
279
+ * `delivered: false` result throws `INPUT_GATED` immediately) or a stalled
280
+ * clock during the wait still leaves no action stuck held.
281
+ */
282
+ async hold(action: string | string[], budget: { simSeconds: number }): Promise<void> {
283
+ // `hold('left', 0.5)` would otherwise send `undefined` sim-seconds down the
284
+ // `holdFor` bridge call — the same options-object-only contract as
285
+ // `waitSimTime`, named for the method the caller actually wrote.
286
+ assertValidWaitForBudget(budget, 'input.hold');
287
+ const actions = Array.isArray(action) ? action : [action];
288
+ if (actions.length === 0) {
289
+ throw new Error('game.input.hold: action array must not be empty');
290
+ }
291
+
292
+ if (actions.length === 1) {
293
+ const single = actions[0] as string;
294
+ let result: VirtualActionResult;
295
+ try {
296
+ result = await this.client.callBridgeAsync<VirtualActionResult>(
297
+ 'holdFor',
298
+ single,
299
+ budget.simSeconds,
300
+ );
301
+ } catch (err) {
302
+ throw rethrowIfHoldTimedOut(err, single, budget.simSeconds);
303
+ }
304
+ if (!result.delivered) throw inputGatedError(single, result.reason);
305
+ return;
306
+ }
307
+
308
+ const pressed: string[] = [];
309
+ try {
310
+ for (const single of actions) {
311
+ const result = await this.client.callBridge<VirtualActionResult>(
312
+ 'input.setVirtualAction',
313
+ single,
314
+ true,
315
+ );
316
+ if (!result.delivered) throw inputGatedError(single, result.reason);
317
+ pressed.push(single);
318
+ }
319
+ await this.client.waitSimTime(budget);
320
+ } finally {
321
+ for (const single of pressed) {
322
+ await this.client
323
+ .callBridge<VirtualActionResult>('input.setVirtualAction', single, false)
324
+ .catch(() => {
325
+ // Best-effort release — a failure here must never mask whatever
326
+ // the try block itself threw (or replace a clean success with a
327
+ // spurious one), and there is nothing more this call can do
328
+ // about a bridge/session that is no longer reachable.
329
+ });
330
+ }
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Keep one or more honest game actions pressed while `observe` runs, then
336
+ * release every action in a `finally` block. This is the evidence-oriented
337
+ * sibling of {@link hold}: `hold()` resolves after release, which is ideal
338
+ * for asserting distance travelled but cannot capture a moving pose or read
339
+ * transient "currently sprinting" state. `whileHeld()` makes that interval
340
+ * explicit without asking tests to hand-roll unsafe set/clear cleanup.
341
+ *
342
+ * The callback uses the same `GameClient` instance already in the test, so
343
+ * it may call `game.waitFor`, `game.state`, `game.screenshot`, or any other
344
+ * ordinary observation. No state is fabricated and the actions still enter
345
+ * through `input.setVirtualAction`/the project's real input map.
346
+ */
347
+ async whileHeld<T>(action: string | string[], observe: () => T | Promise<T>): Promise<T> {
348
+ const actions = Array.isArray(action) ? action : [action];
349
+ if (actions.length === 0) {
350
+ throw new Error('game.input.whileHeld: action array must not be empty');
351
+ }
352
+
353
+ const pressed: string[] = [];
354
+ try {
355
+ for (const single of actions) {
356
+ const result = await this.client.callBridge<VirtualActionResult>(
357
+ 'input.setVirtualAction',
358
+ single,
359
+ true,
360
+ );
361
+ if (!result.delivered) throw inputGatedError(single, result.reason);
362
+ pressed.push(single);
363
+ }
364
+ return await observe();
365
+ } finally {
366
+ for (const single of pressed) {
367
+ await this.client
368
+ .callBridge<VirtualActionResult>('input.setVirtualAction', single, false)
369
+ .catch(() => {
370
+ // Best-effort release: preserve the callback/gating failure while
371
+ // never leaving an action held merely because teardown lost the
372
+ // relay.
373
+ });
374
+ }
375
+ }
376
+ }
377
+
378
+ /**
379
+ * One honest one-shot press — pressed, carried through AT LEAST ONE FULL
380
+ * FIXED TICK, then released, as a single bridge call.
381
+ *
382
+ * The contract is deliberately "a tick services it", not "the bridge
383
+ * accepted it", because the raw primitive underneath (`InputManager.
384
+ * tapVirtualAction`) only QUEUES: `poll()` promotes the queue to that tick's
385
+ * just-pressed set, and nothing else ever does. On a visible tab a tick
386
+ * lands ~16ms later so the difference is invisible; on a HIDDEN tab the host
387
+ * loop is stopped outright, so a queue-only tap sat unserviced and then died
388
+ * — silently, having already reported `delivered: true` (measured:
389
+ * `input.trace: { ticks: [] }`, no event, while `whileHeld` on the same
390
+ * action worked every time). Routing through the `holdFor` primitive is what
391
+ * makes the press TICK-ALIGNED by construction: that call owns the wait, and
392
+ * its hidden-tab leg drives the tick deterministically
393
+ * (`runtime/debug-bridge.ts`'s `waitForHoldBudget`), so a tap can no longer
394
+ * fall between ticks on any transport or any tab state.
395
+ *
396
+ * A gated action still fails loudly and immediately with `INPUT_GATED`,
397
+ * exactly as {@link hold} does — same `delivered:false` contract, same
398
+ * error.
399
+ */
400
+ async tap(action: string): Promise<void> {
401
+ const result = await this.client.callBridgeAsync<VirtualActionResult>(
402
+ 'holdFor',
403
+ action,
404
+ TAP_SIM_SECONDS,
405
+ );
406
+ if (!result.delivered) throw inputGatedError(action, result.reason);
407
+ }
408
+
409
+ /**
410
+ * Sets one action, or the SAME value on several at once — same
411
+ * `string | string[]` shape as {@link hold}, and for the same reason: a
412
+ * bare array used to silently stringify into a single bogus action name
413
+ * instead of being rejected or honored.
414
+ */
415
+ async set(action: string | string[], value: VirtualActionValue): Promise<void> {
416
+ const actions = Array.isArray(action) ? action : [action];
417
+ if (actions.length === 0) {
418
+ throw new Error('game.input.set: action array must not be empty');
419
+ }
420
+ for (const single of actions) {
421
+ const result = await this.client.callBridge<VirtualActionResult>(
422
+ 'input.setVirtualAction',
423
+ single,
424
+ value,
425
+ );
426
+ if (!result.delivered) throw inputGatedError(single, result.reason);
427
+ }
428
+ }
429
+ }
430
+
431
+ export class GameEvents {
432
+ constructor(private readonly client: GameClient) {}
433
+
434
+ async expect(names: string[], opts?: { exact?: boolean; withinTicks?: number }): Promise<void> {
435
+ // Run-4 friction #5: fence on `fenceSeq` (unambiguous — see its doc
436
+ // comment on `GameClientOptions`), not `fenceTick`. The old
437
+ // `snapshot(fenceTick)` call used the tick-based filter, which silently
438
+ // dropped an event emitted at the fence tick itself — exactly what a
439
+ // debug-command handler running right at test start produces.
440
+ const snapshot = await this.client.snapshot(this.client.fenceSeq);
441
+ // M9: pass the REAL fence tick (the test's own start), not
442
+ // `snapshot.events[0].tick` — see events-matcher.ts's doc comment.
443
+ const result = matchEventsSubsequence(names, snapshot.events, this.client.fenceTick, opts);
444
+ if (result.matched) return;
445
+
446
+ const providers = await this.client.providers();
447
+ // D5: real deltas off the fence, not the absolute (since-boot)
448
+ // simSeconds + a hardcoded wallElapsedMs: 0 — the pre-fix code fabricated
449
+ // a "sim speed 0.00x — loop stalled" reading on every single
450
+ // events.expect failure regardless of actual game health.
451
+ const block = assembleFailureBlock({
452
+ headline: `game.events.expect failed: expected ${JSON.stringify(names)} as an ${opts?.exact ? 'exact' : 'ordered subsequence'} of events since test start`,
453
+ simElapsedSeconds: snapshot.time.simSeconds - this.client.fenceSimSeconds,
454
+ wallElapsedMs: Date.now() - this.client.fenceWallMs,
455
+ tick: snapshot.time.tick,
456
+ predicateSource: describeEventsExpectation(names, opts),
457
+ providers,
458
+ lastState: snapshot.state,
459
+ lastEvents: snapshot.events,
460
+ screenshotPath: await this.client.screenshot('events-expect-failure').catch(() => null),
461
+ consoleErrors: this.client.consoleErrors,
462
+ pageErrors: this.client.pageErrors,
463
+ recoveryNotices: this.client.recoveryNotices,
464
+ // Issue #175: same hidden-tab diagnosis toSessionFailure gets below —
465
+ // an events.expect failure deserves the same honesty about WHY the
466
+ // clock looks frozen.
467
+ loopLiveness: snapshot.time.loopLiveness,
468
+ });
469
+ throw new SessionFailure('EVENTS_EXPECT_FAILED', block);
470
+ }
471
+ }
472
+
473
+ /**
474
+ * The `game` fixture value. Every method reaches `window.__vgai` (or its
475
+ * relay-side equivalent) through `this.#transport` — see `bridge-transport.ts`'s
476
+ * module doc for the seam, and this file's own module doc for why it, alone,
477
+ * is allowed to import Playwright (via `PageTransport`, above).
478
+ */
479
+ export class GameClient {
480
+ readonly input = new GameInput(this);
481
+ readonly events = new GameEvents(this);
482
+ readonly fenceTick: number;
483
+ /** Run-4 friction #5: see `GameClientOptions.fenceSeq`. */
484
+ readonly fenceSeq: number;
485
+ /** D5: see `GameClientOptions.fenceSimSeconds`. */
486
+ readonly fenceSimSeconds: number;
487
+ /** D5: see `GameClientOptions.fenceWallMs`. */
488
+ readonly fenceWallMs: number;
489
+ readonly pageErrors: string[];
490
+ readonly consoleErrors: string[];
491
+ /** M13: hidden-tab recovery's own structured notices — kept SEPARATE from
492
+ * `consoleErrors` (see `hidden-recovery.ts`'s hook below and
493
+ * `failure-block.ts`'s `recoveryNotices` member). */
494
+ readonly recoveryNotices: string[] = [];
495
+ // Everything below is `#`-PRIVATE, not `private` — deliberately, and the
496
+ // difference is observable. TypeScript's `private` is erased at compile
497
+ // time: `this.transport = …` leaves an ordinary own property that
498
+ // `Object.getOwnPropertyNames(game)` reports and `game.transport.call(…)`
499
+ // happily invokes. `vgai eval --list` enumerates this object's REAL members
500
+ // (it must — `input`/`events` are instance fields no prototype walk can
501
+ // see), so an erased-private field is a plumbing detail advertised to every
502
+ // agent as a door, right next to the doors it should actually use. `#` is
503
+ // the only privacy the runtime enforces, so it is the only privacy an
504
+ // introspecting listing can respect.
505
+ readonly #transport: BridgeTransport;
506
+ /** Where a labelled `screenshot()` lands — project-scoped by `@vgai/live`, cwd-relative otherwise. Public: a caller reading it is asking a fair question, and `screenshot()` returns a path under it anyway. */
507
+ readonly artifactsDir: string;
508
+ /** See `GameClientOptions.warmSession`. */
509
+ readonly #warmSession: boolean;
510
+ /** See `GameClientOptions.testTitle`. */
511
+ readonly #testTitle: string;
512
+ #screenshotCounter = 0;
513
+ /** Per-test tick-rate samples, fed by every `snapshot()` read (a poll the
514
+ * client was making anyway — zero extra page.evaluate round trips). */
515
+ readonly #tps = new TpsAccumulator();
516
+ /** Hidden-tab recovery (hollowstone field lesson: the engine hard-stops
517
+ * while `document.hidden`). Client-lifetime state so `bringToFront()`
518
+ * fires at most once per test, across ALL waitFor/waitSimTime loops. */
519
+ readonly #hiddenRecovery: HiddenRecoveryDriver;
520
+ /** run-3 friction #2 — throttled "a bridge call is flowing" liveness
521
+ * heartbeat (see bridge-heartbeat.ts's module doc), client-lifetime so
522
+ * the 30s throttle applies across the whole test, not per call site. */
523
+ #bridgeHeartbeat: BridgeHeartbeatState;
524
+
525
+ constructor(opts: GameClientOptions) {
526
+ this.#transport = opts.transport;
527
+ this.pageErrors = opts.pageErrors;
528
+ this.consoleErrors = opts.consoleErrors;
529
+ this.fenceTick = opts.fenceTick;
530
+ this.fenceSeq = opts.fenceSeq;
531
+ this.fenceSimSeconds = opts.fenceSimSeconds;
532
+ this.fenceWallMs = opts.fenceWallMs;
533
+ this.artifactsDir = opts.artifactsDir ?? resolve('.vgai/last-run');
534
+ this.#warmSession = opts.warmSession ?? false;
535
+ this.#testTitle = opts.testTitle ?? 'test';
536
+ this.#bridgeHeartbeat = { lastEmitWallMs: Date.now() };
537
+ this.#hiddenRecovery = new HiddenRecoveryDriver({
538
+ sampleHidden: () => this.#transport.isHidden(),
539
+ bringToFront: () => this.#transport.bringToFront(),
540
+ log: (line) => {
541
+ // M13: structured line on stdout AND into its OWN recoveryNotices
542
+ // collection — NOT consoleErrors, so a real console error and a
543
+ // benign "we recovered a backgrounded tab" notice never conflate in
544
+ // the failure block's console/page-errors section.
545
+ console.log(line);
546
+ this.recoveryNotices.push(line);
547
+ },
548
+ });
549
+ }
550
+
551
+ async state(name: string): Promise<unknown> {
552
+ return this.callBridge<unknown>('state', name);
553
+ }
554
+
555
+ async command(name: string, ...args: unknown[]): Promise<unknown> {
556
+ return this.callBridgeAsync<unknown>('invoke', name, args);
557
+ }
558
+
559
+ async providers(): Promise<ProviderInfo[]> {
560
+ return this.callBridge<ProviderInfo[]>('providers');
561
+ }
562
+
563
+ async commands(): Promise<DebugCommandInfo[]> {
564
+ return this.callBridge<DebugCommandInfo[]>('commands');
565
+ }
566
+
567
+ async snapshot(sinceSeq?: number): Promise<DebugSnapshot> {
568
+ const snap = await this.callBridge<DebugSnapshot>('snapshot', sinceSeq);
569
+ // Both hardening seams hang off the read every poll loop already makes:
570
+ // one tick-rate sample per snapshot, and one hidden-tab-recovery
571
+ // observation (which only pays an extra evaluate once the tick has been
572
+ // frozen for HIDDEN_RECOVERY_STALL_POLLS consecutive reads).
573
+ this.#tps.record(snap.time.tick, Date.now());
574
+ await this.#hiddenRecovery.observeTick(snap.time.tick);
575
+ return snap;
576
+ }
577
+
578
+ /** Fixture-teardown perf read: p50/p95 effective ticks-per-second over all
579
+ * the snapshot polls this test performed. */
580
+ tpsStats(): TpsStats {
581
+ return this.#tps.stats();
582
+ }
583
+
584
+ /** True if the hidden-tab recovery fired (page.bringToFront was called). */
585
+ hiddenRecoveryTriggered(): boolean {
586
+ return this.#hiddenRecovery.wasTriggered();
587
+ }
588
+
589
+ /**
590
+ * D15/T-D15.4 — synchronously drives `budget` worth of sim time/ticks via
591
+ * the live `Game`'s `runTicks` (through the debug bridge), instead of
592
+ * waiting for real wall-clock time to pass. Doctrine (see
593
+ * `fast-forward.ts`'s module doc, which also documents the two honesty
594
+ * decisions this wraps): SETUP/STAGING traversal — reaching a known
595
+ * late-game state fast — not a substitute for real-input proofs, which
596
+ * still run in real ticks. Returns the final `{time, state, events,
597
+ * pageErrors}` snapshot (a completely ordinary `snapshot()` read, taken
598
+ * AFTER the tps baseline reset below, so it never itself corrupts the tps
599
+ * stats either).
600
+ */
601
+ async fastForward(budget: FastForwardBudget, opts?: FastForwardOptions): Promise<DebugSnapshot> {
602
+ // Same options-object-only contract as `waitSimTime` (`ticksForBudget`
603
+ // would otherwise fail on `'simTicks' in 0.5` with a raw TypeError that
604
+ // names neither the method nor the shape).
605
+ assertValidWaitForBudget(budget, 'fastForward');
606
+ const clock: FastForwardClock = {
607
+ runTicksBatch: (n, render) => this.callBridgeVoid('runTicks', n, { render }),
608
+ readTime: async () => {
609
+ // Raw bridge read — deliberately NOT `this.snapshot()`, which would
610
+ // feed the TpsAccumulator (see fast-forward.ts's module doc, point 2).
611
+ const snap = await this.callBridge<DebugSnapshot>('snapshot');
612
+ return { tick: snap.time.tick, simSeconds: snap.time.simSeconds };
613
+ },
614
+ heartbeat: (info) => {
615
+ console.log(`vgai fastForward: ${info.ticksDone}/${info.ticksTotal} ticks driven`);
616
+ },
617
+ };
618
+ await runFastForward(budget, opts ?? {}, clock);
619
+ // The burst is over — reset the baseline so the very next ordinary poll
620
+ // (including the `snapshot()` call right below) treats itself as a fresh
621
+ // "first observation" rather than diffing across the burst's enormous
622
+ // tick delta over a near-zero wall delta.
623
+ this.#tps.resetBaseline();
624
+ return this.snapshot();
625
+ }
626
+
627
+ async waitFor(
628
+ pred: (s: (name: string) => unknown) => boolean,
629
+ budget: WaitForBudget,
630
+ ): Promise<void> {
631
+ try {
632
+ await runWaitFor(
633
+ pred,
634
+ budget,
635
+ {
636
+ snapshot: () => this.snapshot(),
637
+ now: () => Date.now(),
638
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
639
+ // Fixture heartbeat (Wave-6 findings ledger) — real stdout, the
640
+ // same channel a caller watching stdout already treats as
641
+ // liveness (see wait-for.ts's module doc for the emission
642
+ // invariant `maybeHeartbeat` enforces).
643
+ log: (line) => console.log(line),
644
+ },
645
+ this.#testTitle,
646
+ );
647
+ } catch (err) {
648
+ if (err instanceof WaitForTimeoutError) throw await this.toSessionFailure(err);
649
+ throw err;
650
+ }
651
+ }
652
+
653
+ /** Used by `input.hold` — waits for a sim-time delta to pass with no
654
+ * predicate (a predicate that is never true would misreport as a
655
+ * waitFor failure, so this is its own tiny loop, not `runWaitFor` with a
656
+ * false predicate).
657
+ *
658
+ * D2: this loop has the SAME frozen-clock bug `runWaitFor` had — with no
659
+ * predicate at all, a stalled sim clock would poll forever, since
660
+ * `current.time.simSeconds` would never advance. Carries the identical
661
+ * stall guard (`WAIT_FOR_STALL_POLL_LIMIT` consecutive polls with an
662
+ * unchanged tick — see wait-for.ts's doc comment for the rationale),
663
+ * throwing the same `WaitForTimeoutError` shape so it renders through the
664
+ * same honest failure block as an ordinary `waitFor`/`waitSimTime` budget
665
+ * exhaustion. */
666
+ async waitSimTime(budget: { simSeconds: number }): Promise<void> {
667
+ // A positional budget (`waitSimTime(0.5)`) reads `undefined` here and the
668
+ // exit comparison below is then false FOREVER — and against a hidden tab
669
+ // the client drives its own ticks, so the frozen-clock stall guard never
670
+ // fires either. Refuse in the caller's own vocabulary instead of hanging.
671
+ assertValidWaitForBudget(budget, 'waitSimTime');
672
+ const startWall = Date.now();
673
+ const start = await this.snapshot();
674
+ let lastTick: number | null = null;
675
+ let stalledPolls = 0;
676
+ // Fixture heartbeat (Wave-6 findings ledger) — same invariant as
677
+ // `wait-for.ts`'s `runWaitFor`: a heartbeat requires BOTH 60s of wall
678
+ // silence AND the tick having advanced since the last one emitted, so a
679
+ // genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
680
+ // goes heartbeat-silent well before this loop's own guard ever needs to.
681
+ let heartbeat: HeartbeatState = { lastEmitWallMs: startWall, lastEmitTick: start.time.tick };
682
+ for (;;) {
683
+ const current = await this.snapshot();
684
+ if (current.time.simSeconds - start.time.simSeconds >= budget.simSeconds) return;
685
+ stalledPolls = lastTick !== null && current.time.tick === lastTick ? stalledPolls + 1 : 0;
686
+ lastTick = current.time.tick;
687
+ if (stalledPolls >= WAIT_FOR_STALL_POLL_LIMIT) {
688
+ throw await this.toSessionFailure(
689
+ new WaitForTimeoutError({
690
+ budget,
691
+ startSnapshot: start,
692
+ lastSnapshot: current,
693
+ wallElapsedMs: Date.now() - startWall,
694
+ predicateSource:
695
+ '(no predicate — game.input.hold is waiting for a sim-time delta to pass)',
696
+ touchedProviders: [],
697
+ }),
698
+ );
699
+ }
700
+ const heartbeatResult = maybeHeartbeat({
701
+ nowMs: Date.now(),
702
+ tick: current.time.tick,
703
+ simSeconds: current.time.simSeconds,
704
+ testTitle: this.#testTitle,
705
+ state: heartbeat,
706
+ });
707
+ heartbeat = heartbeatResult.state;
708
+ if (heartbeatResult.line) console.log(heartbeatResult.line);
709
+ await new Promise((r) => setTimeout(r, 150));
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Capture the running game to disk and return the ABSOLUTE path written.
715
+ *
716
+ * `labelOrPath` is read as a LABEL when it is a bare identifier
717
+ * (`'waitfor-timeout'`) — numbered into the artifacts directory, the
718
+ * long-standing spec behaviour — and as a DESTINATION PATH the moment it
719
+ * carries a separator or a file extension, in which case the bytes land
720
+ * exactly there (relative to `process.cwd()`). See
721
+ * `screenshot-target.ts`'s header for the live incident that made this
722
+ * distinction mandatory: a caller naming a path used to get "success" and
723
+ * an empty path.
724
+ */
725
+ async screenshot(labelOrPath: string): Promise<string> {
726
+ const target = resolveScreenshotTarget({
727
+ arg: labelOrPath,
728
+ artifactsDir: this.artifactsDir,
729
+ sequence: this.#screenshotCounter + 1,
730
+ cwd: process.cwd(),
731
+ });
732
+ if (target.consumedSequence) this.#screenshotCounter += 1;
733
+ await mkdir(dirname(target.path), { recursive: true });
734
+ await this.#transport.screenshot(target.path);
735
+ return target.path;
736
+ }
737
+
738
+ /**
739
+ * Wave-2 "one dialect, full capability" — runs a UI-automation step
740
+ * written as a literal Playwright `async (page) => {...}` (interface
741
+ * doctrine §3.2/§4 rung 4: "the AI should think it is basically just
742
+ * executing Playwright"). Under `PageTransport` this drives the REAL
743
+ * `Page` (real closures, real hit-testing); under `RelayTransport` it
744
+ * posts the step's own `toString()` source to the editor dev server's
745
+ * `page-script` op, which reconstructs it in-page against
746
+ * `playwright-shim.ts`'s in-page shim (`isTrusted: false` synthetic
747
+ * DOM events, no real hit testing). This is the honest input path for a
748
+ * React-only DOM game; canvas gameplay continues to use `game.input.*`.
749
+ * Write specs as if ALWAYS serialized — inline every value the step needs,
750
+ * never close over imported `expect`/helpers/outer variables, and return
751
+ * observations to assert outside the callback. See `bridge-transport.ts`'s
752
+ * `runPageScript` doc comment for the full contract this method wraps.
753
+ */
754
+ async page<T = unknown>(step: (page: Page) => T | Promise<T>): Promise<T> {
755
+ const erased = (arg: unknown) => step(arg as Page);
756
+ const outcome = await this.#transport.runPageScript(step.toString(), erased);
757
+ return this.unwrap<T>(outcome);
758
+ }
759
+
760
+ private async toSessionFailure(err: WaitForTimeoutError): Promise<SessionFailure> {
761
+ const providers = await this.providers();
762
+ const screenshotPath = await this.screenshot('waitfor-timeout').catch(() => null);
763
+ const block = assembleFailureBlock({
764
+ headline: err.message,
765
+ simElapsedSeconds:
766
+ err.info.lastSnapshot.time.simSeconds - err.info.startSnapshot.time.simSeconds,
767
+ wallElapsedMs: err.info.wallElapsedMs,
768
+ tick: err.info.lastSnapshot.time.tick,
769
+ predicateSource: err.info.predicateSource,
770
+ providers,
771
+ touchedProviders: err.info.touchedProviders,
772
+ lastState: err.info.lastSnapshot.state,
773
+ lastEvents: err.info.lastSnapshot.events,
774
+ screenshotPath,
775
+ consoleErrors: this.consoleErrors,
776
+ pageErrors: this.pageErrors,
777
+ recoveryNotices: this.recoveryNotices,
778
+ // Issue #175 — "state must never claim health it cannot observe": a
779
+ // waitFor/waitSimTime timeout with a ~0x sim-speed ratio used to read
780
+ // as a generic "loop is stalled" no matter WHY the clock was frozen.
781
+ // `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
782
+ // before this failure fires (see this class's constructor) — if the
783
+ // loop is STILL hidden-paused here, recovery couldn't reach the tab
784
+ // (headless run, no-op bringToFront), and the failure block must say
785
+ // so explicitly rather than leaving an agent to guess "stalled" for a
786
+ // tab that is simply backgrounded.
787
+ loopLiveness: err.info.lastSnapshot.time.loopLiveness,
788
+ });
789
+ return new SessionFailure('WAIT_FOR_TIMEOUT', block);
790
+ }
791
+
792
+ /** run-3 friction #2 — called at the top of every bridge dispatch, before
793
+ * the await, so a burst of short calls (e.g. many `hold()`s) keeps
794
+ * printing throttled liveness even while individually near-silent. See
795
+ * bridge-heartbeat.ts's module doc for why this needs no sim-tick
796
+ * awareness the way `wait-for.ts`'s poll-loop heartbeat does. */
797
+ private emitBridgeHeartbeatIfDue(method: string): void {
798
+ const result = maybeBridgeHeartbeat({
799
+ nowMs: Date.now(),
800
+ method,
801
+ testTitle: this.#testTitle,
802
+ state: this.#bridgeHeartbeat,
803
+ });
804
+ this.#bridgeHeartbeat = result.state;
805
+ if (result.line) console.log(result.line);
806
+ }
807
+
808
+ /** @internal exposed for GameInput/GameEvents in this module only. */
809
+ async callBridge<T>(method: string, ...callArgs: unknown[]): Promise<T> {
810
+ this.emitBridgeHeartbeatIfDue(method);
811
+ const outcome = await this.#transport.call(method, callArgs);
812
+ return this.unwrap<T>(outcome);
813
+ }
814
+
815
+ /** @internal like `callBridge`, but awaits the page-side call (for async
816
+ * bridge methods like `invoke`). */
817
+ async callBridgeAsync<T>(method: string, ...callArgs: unknown[]): Promise<T> {
818
+ this.emitBridgeHeartbeatIfDue(method);
819
+ const outcome = await this.#transport.callAsync(method, callArgs);
820
+ return this.unwrap<T>(outcome);
821
+ }
822
+
823
+ /** @internal void-returning convenience (clearVirtualActions). */
824
+ async callBridgeVoid(method: string, ...callArgs: unknown[]): Promise<void> {
825
+ await this.callBridge<void>(method, ...callArgs);
826
+ }
827
+
828
+ private unwrap<T>(outcome: BridgeCallOutcome): T {
829
+ if (!outcome.ok) {
830
+ const error = outcome.error ?? { code: undefined, message: 'unknown bridge error' };
831
+ const message = appendWarmSessionHint(error.message, error.code, this.#warmSession);
832
+ throw new SessionError(error.code ?? 'DEBUG_COMMAND_FAILED', message, error.data);
833
+ }
834
+ return outcome.result as T;
835
+ }
836
+ }