@vgai/live 0.5.2 → 0.5.3

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,149 @@
1
+ /**
2
+ * Hidden-tab recovery — pure decision logic, ported from hollowstone's field
3
+ * lessons (the engine hard-stops its loop while `document.hidden`, and a
4
+ * backgrounded tab therefore looks identical to a genuinely stalled
5
+ * game). No Playwright import here: `client.ts`'s `GameClient.snapshot()` —
6
+ * the one read every `waitFor`/`waitSimTime` poll already performs — feeds a
7
+ * `HiddenRecoveryDriver` one tick observation per poll, and only the
8
+ * caller-supplied hooks (wired up in `client.ts`, the one file allowed to
9
+ * touch a live `Page`) perform the actual `page.evaluate('document.hidden')`
10
+ * sample and `page.bringToFront()` call. Unit tests feed the same driver
11
+ * scripted hooks/observations instead.
12
+ *
13
+ * Contract (deliberately narrow): after `HIDDEN_RECOVERY_STALL_POLLS`
14
+ * consecutive polls with an unmoved sim tick, sample `document.hidden` once
15
+ * per poll until it reads `true`, then call `bringToFront()` EXACTLY ONCE
16
+ * (the `recovered` flag latches shut) and keep polling — no wall-clock
17
+ * timeout is added here or anywhere downstream. If the clock stays frozen
18
+ * after recovery, the normal `runWaitFor` sim-time budget exhausts and
19
+ * throws `WaitForTimeoutError` as it always would; that failure block's
20
+ * ~0x sim-speed ratio is what diagnoses a still-stalled loop, not this
21
+ * module reacting a second time.
22
+ */
23
+
24
+ export const HIDDEN_RECOVERY_STALL_POLLS = 10;
25
+
26
+ export interface HiddenRecoveryState {
27
+ /** Consecutive polls (so far) where the sim tick has not advanced. */
28
+ stalledPolls: number;
29
+ /** Latches true the moment `bringToFront` has fired once — never resets,
30
+ * even if the tick later moves and then stalls again, per "once". */
31
+ recovered: boolean;
32
+ }
33
+
34
+ export const initialHiddenRecoveryState: HiddenRecoveryState = {
35
+ stalledPolls: 0,
36
+ recovered: false,
37
+ };
38
+
39
+ export interface PollObservation {
40
+ /** True when this poll's snapshot tick equals the previous poll's tick. */
41
+ tickUnchanged: boolean;
42
+ /** `document.hidden`, sampled by the caller — only meaningful (and only
43
+ * ever sampled by real callers) once `shouldSampleHidden` says so;
44
+ * `undefined` means "not sampled this poll". */
45
+ hidden?: boolean | undefined;
46
+ }
47
+
48
+ export type HiddenRecoveryAction = 'none' | 'bring-to-front';
49
+
50
+ /** Whether THIS poll's caller should bother sampling `document.hidden` at
51
+ * all — an efficiency guard so a healthy, moving game never pays an extra
52
+ * `page.evaluate()` per poll. True only once the stall threshold is about
53
+ * to be (or has been) reached, the tick is (still) unchanged, and recovery
54
+ * hasn't already fired. */
55
+ export function shouldSampleHidden(state: HiddenRecoveryState, tickUnchanged: boolean): boolean {
56
+ if (state.recovered || !tickUnchanged) return false;
57
+ return state.stalledPolls + 1 >= HIDDEN_RECOVERY_STALL_POLLS;
58
+ }
59
+
60
+ /** One pure transition. Called once per `runWaitFor` poll with the previous
61
+ * state and this poll's observation; returns the next state plus the
62
+ * action the caller should take. */
63
+ export function stepHiddenRecovery(
64
+ state: HiddenRecoveryState,
65
+ obs: PollObservation,
66
+ ): { state: HiddenRecoveryState; action: HiddenRecoveryAction } {
67
+ if (!obs.tickUnchanged) {
68
+ return { state: { stalledPolls: 0, recovered: state.recovered }, action: 'none' };
69
+ }
70
+ const stalledPolls = state.stalledPolls + 1;
71
+ if (state.recovered || obs.hidden !== true) {
72
+ return { state: { stalledPolls, recovered: state.recovered }, action: 'none' };
73
+ }
74
+ if (stalledPolls >= HIDDEN_RECOVERY_STALL_POLLS) {
75
+ return { state: { stalledPolls, recovered: true }, action: 'bring-to-front' };
76
+ }
77
+ return { state: { stalledPolls, recovered: false }, action: 'none' };
78
+ }
79
+
80
+ /** The page-facing side effects the driver needs, injected so this module
81
+ * never imports Playwright (`client.ts` supplies the real ones; tests
82
+ * supply scripted fakes). */
83
+ export interface HiddenRecoveryHooks {
84
+ /** Real implementation: `page.evaluate(() => document.hidden)`. */
85
+ sampleHidden(): Promise<boolean> | boolean;
86
+ /** Real implementation: `page.bringToFront()`. */
87
+ bringToFront(): Promise<void> | void;
88
+ /** Real implementation: `console.log(line)` + push onto the client's
89
+ * console-errors collection so the line also surfaces in an eventual
90
+ * failure block's console-errors section. */
91
+ log(line: string): void;
92
+ }
93
+
94
+ /** Renders the structured recovery line — one stable, greppable shape. */
95
+ export function hiddenRecoveryLogLine(tick: number): string {
96
+ return (
97
+ `vgai: hidden-tab recovery — sim tick frozen at ${tick} for ` +
98
+ `${HIDDEN_RECOVERY_STALL_POLLS} consecutive polls while document.hidden=true; ` +
99
+ 'calling page.bringToFront() once'
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Stateful wrapper over the pure transition above, fed one tick observation
105
+ * per poll (`GameClient.snapshot()` calls `observeTick` on every read). All
106
+ * side effects go through the injected hooks; a hook failure (page already
107
+ * closed, say) is swallowed — a recovery attempt must never be what fails a
108
+ * test.
109
+ */
110
+ export class HiddenRecoveryDriver {
111
+ private state: HiddenRecoveryState = initialHiddenRecoveryState;
112
+ private lastTick: number | null = null;
113
+ private didTrigger = false;
114
+
115
+ constructor(private readonly hooks: HiddenRecoveryHooks) {}
116
+
117
+ /** True once `bringToFront()` has fired (at most once per driver/test). */
118
+ wasTriggered(): boolean {
119
+ return this.didTrigger;
120
+ }
121
+
122
+ async observeTick(tick: number): Promise<void> {
123
+ const tickUnchanged = this.lastTick !== null && tick === this.lastTick;
124
+ this.lastTick = tick;
125
+
126
+ let hidden: boolean | undefined;
127
+ if (shouldSampleHidden(this.state, tickUnchanged)) {
128
+ try {
129
+ hidden = await this.hooks.sampleHidden();
130
+ } catch {
131
+ hidden = undefined;
132
+ }
133
+ }
134
+
135
+ const { state, action } = stepHiddenRecovery(this.state, { tickUnchanged, hidden });
136
+ this.state = state;
137
+
138
+ if (action === 'bring-to-front') {
139
+ this.didTrigger = true;
140
+ this.hooks.log(hiddenRecoveryLogLine(tick));
141
+ try {
142
+ await this.hooks.bringToFront();
143
+ } catch {
144
+ // Same rule as sampleHidden: never let the recovery attempt itself
145
+ // throw into a spec — the sim-time budget remains the only failure.
146
+ }
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The game-control client `@vgai/live` binds as `game`: a `GameClient` over a
3
+ * `BridgeTransport`, plus the pure logic it is built from (wait budgets, event
4
+ * matching, failure blocks, fast-forward planning, hidden-tab recovery, perf
5
+ * sampling, screenshot-target resolution).
6
+ *
7
+ * Two transports answer the same seam (`bridge-transport.ts`): `RelayTransport`
8
+ * drives a live `vgai edit` session over the editor dev server's wire — the one
9
+ * `@vgai/live` itself uses — and `PageTransport` drives a Playwright `Page`
10
+ * directly, for a caller that owns its own browser. Every method body on
11
+ * `GameClient` is written against the seam, never against either transport.
12
+ *
13
+ * This barrel exists so `../index.ts` re-exports one coherent surface instead of
14
+ * a dozen sibling paths; nothing here imports anything from `../`.
15
+ */
16
+
17
+ export type { BridgeHeartbeatState } from './bridge-heartbeat.js';
18
+ export { BRIDGE_HEARTBEAT_INTERVAL_MS, formatBridgeHeartbeatLine } from './bridge-heartbeat.js';
19
+ export type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
20
+ export type { GameClientOptions } from './client.js';
21
+ export { GameClient, GameEvents, GameInput, PageTransport } from './client.js';
22
+ export type { SessionErrorCode } from './errors.js';
23
+ export { SESSION_ERROR_CODES, SessionError, SessionFailure } from './errors.js';
24
+ export type { EventsMatchOptions, EventsMatchResult } from './events-matcher.js';
25
+ export { matchEventsSubsequence } from './events-matcher.js';
26
+ export type {
27
+ AssembledFailureBlock,
28
+ FailureBlockContext,
29
+ SessionFailureData,
30
+ } from './failure-block.js';
31
+ export type {
32
+ FastForwardBudget,
33
+ FastForwardClock,
34
+ FastForwardOptions,
35
+ FastForwardRenderMode,
36
+ FastForwardTime,
37
+ } from './fast-forward.js';
38
+ export {
39
+ DEFAULT_FAST_FORWARD_BATCH_TICKS,
40
+ DEFAULT_FIXED_DT,
41
+ planFastForwardBatches,
42
+ runFastForward,
43
+ ticksForBudget,
44
+ } from './fast-forward.js';
45
+ export type {
46
+ HiddenRecoveryAction,
47
+ HiddenRecoveryHooks,
48
+ HiddenRecoveryState,
49
+ PollObservation,
50
+ } from './hidden-recovery.js';
51
+ export {
52
+ HIDDEN_RECOVERY_STALL_POLLS,
53
+ HiddenRecoveryDriver,
54
+ hiddenRecoveryLogLine,
55
+ initialHiddenRecoveryState,
56
+ shouldSampleHidden,
57
+ stepHiddenRecovery,
58
+ } from './hidden-recovery.js';
59
+ export type { TpsStats } from './perf-sampling.js';
60
+ export {
61
+ computeTicksPerSecond,
62
+ percentile,
63
+ simSpeedRatio,
64
+ summarizeTpsSamples,
65
+ TpsAccumulator,
66
+ } from './perf-sampling.js';
67
+ export type { RelayTransportOptions } from './relay-transport.js';
68
+ export { RelayTransport } from './relay-transport.js';
69
+ export type {
70
+ ScreenshotArgKind,
71
+ ScreenshotTarget,
72
+ ScreenshotTargetInput,
73
+ } from './screenshot-target.js';
74
+ export {
75
+ classifyScreenshotArg,
76
+ resolveScreenshotTarget,
77
+ sanitizeScreenshotLabel,
78
+ } from './screenshot-target.js';
79
+ export type { CappedJson } from './state-cap.js';
80
+ export { capJson } from './state-cap.js';
81
+ export type {
82
+ DebugBridgeInput,
83
+ DebugCommandInfo,
84
+ DebugSnapshot,
85
+ ProviderInfo,
86
+ RunTicksOptions,
87
+ TickStampedEvent,
88
+ ValueTier,
89
+ VgaiBridgeHandle,
90
+ VirtualActionResult,
91
+ VirtualActionValue,
92
+ } from './types.js';
93
+ export type { WaitForBudget } from './wait-for.js';
94
+ export {
95
+ assertValidWaitForBudget,
96
+ WAIT_FOR_STALL_POLL_LIMIT,
97
+ WAIT_FOR_TIMEOUT_OPTION_MESSAGE,
98
+ } from './wait-for.js';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure per-test perf math — no Playwright. Two independent measures:
3
+ * 1. Sim-speed ratio for the whole test (`simSpeedRatio`) — the same
4
+ * sim-seconds/wall-seconds ratio `failure-block.ts` renders on a
5
+ * timeout, computed instead between the fixture's fence snapshot and a
6
+ * final snapshot taken at teardown, regardless of whether the test
7
+ * passed or failed.
8
+ * 2. Effective ticks-per-second, sampled cheaply off polls `game.waitFor`
9
+ * was already doing (no extra `page.evaluate()` round trips): each poll
10
+ * contributes one `(tickDelta / wallDeltaMs)` sample, and this module
11
+ * summarizes the accumulated samples as p50/p95.
12
+ */
13
+
14
+ /** One poll's instantaneous ticks-per-second, or `null` when `wallDeltaMs`
15
+ * is non-positive (first poll / a clock with sub-millisecond resolution) —
16
+ * callers should simply skip a `null` sample rather than recording it. */
17
+ export function computeTicksPerSecond(tickDelta: number, wallDeltaMs: number): number | null {
18
+ if (wallDeltaMs <= 0) return null;
19
+ return (tickDelta / wallDeltaMs) * 1000;
20
+ }
21
+
22
+ /** Nearest-rank percentile (1-indexed rank `ceil(p/100 * n)`, clamped into
23
+ * range) over an ALREADY-SORTED-ASCENDING array. `0` on an empty input. */
24
+ export function percentile(sortedAscending: readonly number[], p: number): number {
25
+ const n = sortedAscending.length;
26
+ if (n === 0) return 0;
27
+ const rank = Math.min(n, Math.max(1, Math.ceil((p / 100) * n)));
28
+ return sortedAscending[rank - 1] as number;
29
+ }
30
+
31
+ export interface TpsStats {
32
+ p50: number;
33
+ p95: number;
34
+ sampleCount: number;
35
+ }
36
+
37
+ /** Summarizes raw ticks-per-second samples (any order) into p50/p95 +
38
+ * count. `{ p50: 0, p95: 0, sampleCount: 0 }` when there are no samples
39
+ * (a test that never called `game.waitFor` — honestly reported as zero
40
+ * samples, never fabricated). */
41
+ export function summarizeTpsSamples(samples: readonly number[]): TpsStats {
42
+ const sorted = [...samples].sort((a, b) => a - b);
43
+ return {
44
+ p50: percentile(sorted, 50),
45
+ p95: percentile(sorted, 95),
46
+ sampleCount: sorted.length,
47
+ };
48
+ }
49
+
50
+ /** Whole-test sim-speed ratio: sim-seconds elapsed over wall-seconds
51
+ * elapsed. `0` when `wallMs` is non-positive (mirrors `failure-block.ts`'s
52
+ * own `ratioLine` degrade-to-zero rule for a zero/negative denominator). */
53
+ export function simSpeedRatio(simSecondsDelta: number, wallMs: number): number {
54
+ return wallMs > 0 ? simSecondsDelta / (wallMs / 1000) : 0;
55
+ }
56
+
57
+ /**
58
+ * Accumulates one tick-rate sample per snapshot read. `GameClient.snapshot()`
59
+ * calls `record(tick, wallNowMs)` on every read it was already doing (waitFor
60
+ * polls, waitSimTime polls, explicit snapshots) — no extra `page.evaluate()`
61
+ * round trips. The first observation only seeds the baseline; a NEGATIVE
62
+ * tick delta (a page reload reset the sim clock) reseeds rather than
63
+ * recording a nonsense sample; a zero tick delta IS recorded (0 ticks/s is
64
+ * the honest "loop frozen" signal the summary exists to surface).
65
+ */
66
+ export class TpsAccumulator {
67
+ private last: { tick: number; wallMs: number } | null = null;
68
+ private readonly samples: number[] = [];
69
+
70
+ record(tick: number, wallMs: number): void {
71
+ const last = this.last;
72
+ this.last = { tick, wallMs };
73
+ if (last === null) return;
74
+ const tickDelta = tick - last.tick;
75
+ if (tickDelta < 0) return;
76
+ const tps = computeTicksPerSecond(tickDelta, wallMs - last.wallMs);
77
+ if (tps !== null) this.samples.push(tps);
78
+ }
79
+
80
+ /** D15: forgets the baseline (not the accumulated samples) — the NEXT
81
+ * `record()` call becomes a fresh "first observation" (seeds only, no
82
+ * sample), exactly like the very first `record()` this accumulator ever
83
+ * sees. `client.ts`'s `fastForward` calls this right after a burst of
84
+ * `runTicks`, so the burst's enormous tick delta over a near-zero wall
85
+ * delta is never diffed into a nonsense tps sample (`fast-forward.ts`'s
86
+ * module doc, point 2) — real per-poll sampling simply resumes from here. */
87
+ resetBaseline(): void {
88
+ this.last = null;
89
+ }
90
+
91
+ stats(): TpsStats {
92
+ return summarizeTpsSamples(this.samples);
93
+ }
94
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * #140 — drives `window.__vgai` through the editor dev-server's SESSION WIRE
3
+ * (`POST /__editor/command`, the same relay `vgai play`/`vgai select`/every
4
+ * other `EditorClient` method already uses — see `command-listener.ts`'s
5
+ * `bridge-call`/`bridge-screenshot` cases, the server-side half) instead of
6
+ * Playwright's `page.evaluate` (`client.ts`'s `PageTransport`). Used by
7
+ * `vgai eval`: the script drives the game INSIDE the already-open editor tab
8
+ * a human is watching, with zero new browser windows and zero extra vite
9
+ * instances.
10
+ *
11
+ * The `bridge-call` op is a session-generic primitive (see
12
+ * `command-listener.ts`'s `dispatchBridgeMethod` doc comment) — this
13
+ * transport issues one HTTP round trip per call and carries no state beyond
14
+ * the port, so it (or a sibling built the same way) is equally usable by a
15
+ * one-shot CLI/REPL call, not just a whole scripted run.
16
+ *
17
+ * Deliberately imports nothing from `@playwright/test` — see
18
+ * `bridge-transport.ts`'s module doc for why that matters (no browser launch
19
+ * anywhere in this path).
20
+ */
21
+
22
+ import { mkdir, writeFile } from 'node:fs/promises';
23
+ import { dirname } from 'node:path';
24
+ import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
25
+
26
+ export interface RelayTransportOptions {
27
+ /** The live editor session's dev-server port (e.g. from
28
+ * `findLiveEditorSession`/`vgai edit`). */
29
+ port: number;
30
+ /** Per-call fetch timeout, ms — default covers an ordinary sync-style
31
+ * bridge call; `callAsync` (debug `invoke`, which may run arbitrary game
32
+ * code) gets its own longer budget regardless of this override, see
33
+ * `INVOKE_TIMEOUT_MS`. */
34
+ timeoutMs?: number;
35
+ /**
36
+ * WHICH mounted game these calls address, when the editor has more than one.
37
+ *
38
+ * Omitted is the single-player case and stays the default: the browser
39
+ * resolves the sole live instance. With several mounted it REFUSES rather
40
+ * than guessing (`systemsForInstance`), because the dangerous failure there
41
+ * is not an error — it is success on the wrong game, with every log line
42
+ * reading fine.
43
+ *
44
+ * Rides on `bridge-call` only. A screenshot captures the page, not an
45
+ * instance, and `page-script` reaches the page itself.
46
+ */
47
+ instance?: string;
48
+ }
49
+
50
+ /** The `/__editor/command` wire shape for a relayed command — see
51
+ * `commandResponseFor` (`packages/editor/server/server-utils.ts`): `data`'s
52
+ * fields are spread at the TOP level of the JSON body, not nested — this
53
+ * interface reflects that verbatim, it is not a transcription error. */
54
+ interface RelayCommandBody {
55
+ ok: boolean;
56
+ error?: string;
57
+ result?: unknown;
58
+ code?: string;
59
+ [key: string]: unknown;
60
+ }
61
+
62
+ const DEFAULT_TIMEOUT_MS = 10_000;
63
+ /** `invoke` dispatches to arbitrary game-registered debug commands — give it
64
+ * real headroom rather than the ordinary sync-call budget above. */
65
+ const INVOKE_TIMEOUT_MS = 60_000;
66
+ const SCREENSHOT_TIMEOUT_MS = 15_000;
67
+ /** A `page-script` step may itself poll (`locator.waitFor`) — give it the
68
+ * same headroom as `invoke` rather than the ordinary sync-call budget. */
69
+ const PAGE_SCRIPT_TIMEOUT_MS = 60_000;
70
+
71
+ /** How long one `/__editor/state` visibility sample stays good for. Short
72
+ * enough that foregrounding the tab mid-run is noticed almost immediately,
73
+ * long enough that the per-leg preflight doesn't double a bot's request
74
+ * count. */
75
+ const HIDDEN_SAMPLE_TTL_MS = 500;
76
+
77
+ export class RelayTransport implements BridgeTransport {
78
+ private readonly baseUrl: string;
79
+ private readonly timeoutMs: number;
80
+ private hiddenDriveLastWallMs: number | null = null;
81
+ private hiddenDriveAnnounced = false;
82
+ private forceHiddenDrive = false;
83
+ private hiddenCache: { hidden: boolean; atMs: number } | null = null;
84
+ private readonly instance: string | undefined;
85
+
86
+ constructor(opts: RelayTransportOptions) {
87
+ this.baseUrl = `http://127.0.0.1:${opts.port}`;
88
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
89
+ this.instance = opts.instance;
90
+ }
91
+
92
+ private async postCommand(
93
+ body: Record<string, unknown>,
94
+ timeoutMs: number,
95
+ ): Promise<RelayCommandBody> {
96
+ const res = await fetch(`${this.baseUrl}/__editor/command`, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify(body),
100
+ signal: AbortSignal.timeout(timeoutMs),
101
+ });
102
+ return (await res.json()) as RelayCommandBody;
103
+ }
104
+
105
+ /** Maps the relay's wire body back onto the transport-neutral
106
+ * `BridgeCallOutcome` — `result` on success, `code`/`error`/the rest of
107
+ * `data` on failure. This is the exact property the round-trip unit test
108
+ * proves: `code`/`data` survive byte-equivalent to `PageTransport`'s own
109
+ * `unwrap()` path. */
110
+ private toBridgeOutcome(body: RelayCommandBody): BridgeCallOutcome {
111
+ if (body.ok) return { ok: true, result: body.result };
112
+ const { ok: _ok, error, code, result: _result, ...rest } = body;
113
+ return {
114
+ ok: false,
115
+ error: {
116
+ code,
117
+ message: error ?? 'unknown relay error',
118
+ data: Object.keys(rest).length > 0 ? rest : undefined,
119
+ },
120
+ };
121
+ }
122
+
123
+ private async bridgeCall(
124
+ method: string,
125
+ callArgs: unknown[],
126
+ timeoutMs: number,
127
+ ): Promise<BridgeCallOutcome> {
128
+ try {
129
+ // `instance` is OMITTED, not sent as undefined, when unset: the wire
130
+ // body is JSON, and an explicit `"instance": null` would have to be
131
+ // distinguished from absence on the far side for no gain.
132
+ const body = await this.postCommand(
133
+ {
134
+ type: 'bridge-call',
135
+ method,
136
+ callArgs,
137
+ ...(this.instance !== undefined ? { instance: this.instance } : {}),
138
+ },
139
+ timeoutMs,
140
+ );
141
+ return this.toBridgeOutcome(body);
142
+ } catch (err) {
143
+ // Never hang, never throw across the transport boundary — a relay
144
+ // that's unreachable (no editor connected, server gone, timeout) is
145
+ // reported the same structured way an in-page bridge-not-installed
146
+ // failure is (see `client.ts`'s `bridgeCallInPage`).
147
+ return {
148
+ ok: false,
149
+ error: {
150
+ code: 'RELAY_UNREACHABLE',
151
+ message:
152
+ `vgai: could not reach the editor dev server relay at ${this.baseUrl} — ` +
153
+ `${err instanceof Error ? err.message : String(err)}`,
154
+ },
155
+ };
156
+ }
157
+ }
158
+
159
+ /**
160
+ * The PREFLIGHT every leg shares: sample the tab's visibility (through the
161
+ * short TTL cache above) and, the first time it reads hidden, say so ONCE on
162
+ * stdout in a stable, greppable line naming the cause and the fix.
163
+ *
164
+ * It runs on every leg, not just `call('snapshot')`, because that is where
165
+ * the measured gap was: a bot that drives the game with `hold`/`command` and
166
+ * reads through them — the shape `npm run playtest` actually has — could run
167
+ * its entire session against a backgrounded tab and never be told, so a
168
+ * later failure read as a generic relay timeout instead of "your tab is
169
+ * hidden". Returns whether the tab is hidden so `call` can decide whether to
170
+ * also drive ticks.
171
+ */
172
+ private async preflightHidden(): Promise<boolean> {
173
+ const hidden = this.forceHiddenDrive || (await this.isHiddenCached());
174
+ if (hidden && !this.hiddenDriveAnnounced) {
175
+ this.hiddenDriveAnnounced = true;
176
+ process.stdout.write(
177
+ 'vgai: the editor tab is HIDDEN — the engine hidden-pauses its loop while the tab ' +
178
+ 'is backgrounded, so this run drives deterministic runTicks through the session relay. ' +
179
+ 'Bring the editor tab to the foreground for real-time play.\n',
180
+ );
181
+ }
182
+ return hidden;
183
+ }
184
+
185
+ /** `isHidden()` is an HTTP round trip; the preflight now runs on every leg,
186
+ * so a short TTL keeps that from multiplying a bot's request count while
187
+ * still reacting to a tab the human foregrounds mid-run. */
188
+ private async isHiddenCached(): Promise<boolean> {
189
+ const now = Date.now();
190
+ if (this.hiddenCache && now - this.hiddenCache.atMs < HIDDEN_SAMPLE_TTL_MS) {
191
+ return this.hiddenCache.hidden;
192
+ }
193
+ const hidden = await this.isHidden();
194
+ this.hiddenCache = { hidden, atMs: now };
195
+ return hidden;
196
+ }
197
+
198
+ async call(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome> {
199
+ const hidden = await this.preflightHidden();
200
+ if (method === 'snapshot') {
201
+ if (hidden) {
202
+ const now = Date.now();
203
+ const elapsed =
204
+ this.hiddenDriveLastWallMs === null ? 1000 / 60 : now - this.hiddenDriveLastWallMs;
205
+ this.hiddenDriveLastWallMs = now;
206
+ const ticks = Math.max(1, Math.min(30, Math.round(elapsed / (1000 / 60))));
207
+ const driven = await this.bridgeCall(
208
+ 'runTicks',
209
+ [ticks, { render: 'last' }],
210
+ this.timeoutMs,
211
+ );
212
+ if (!driven.ok) return driven;
213
+ } else {
214
+ this.hiddenDriveLastWallMs = null;
215
+ this.forceHiddenDrive = false;
216
+ }
217
+ }
218
+ return this.bridgeCall(method, callArgs, this.timeoutMs);
219
+ }
220
+
221
+ async callAsync(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome> {
222
+ await this.preflightHidden();
223
+ return this.bridgeCall(method, callArgs, INVOKE_TIMEOUT_MS);
224
+ }
225
+
226
+ async isHidden(): Promise<boolean> {
227
+ try {
228
+ const res = await fetch(`${this.baseUrl}/__editor/state`, {
229
+ signal: AbortSignal.timeout(this.timeoutMs),
230
+ });
231
+ const state = (await res.json()) as {
232
+ presence?: { visibility?: string; focused?: boolean } | null;
233
+ };
234
+ return state.presence?.visibility === 'hidden';
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * A relay cannot foreground a person's browser tab. If the generic hidden
242
+ * recovery driver reaches this hook, force the same deterministic stepping
243
+ * path `call('snapshot')` normally activates from `/__editor/state`.
244
+ */
245
+ async bringToFront(): Promise<void> {
246
+ this.forceHiddenDrive = true;
247
+ }
248
+
249
+ async screenshot(path: string): Promise<void> {
250
+ // Same preflight as every other leg — a hidden tab's canvas is a provably
251
+ // stale frame, and `refreshHiddenFrame` is what asks the relay for a
252
+ // deterministic one-tick refresh instead of a `BRIDGE_SCREENSHOT_STALE`
253
+ // refusal (`command-listener.ts`'s `handleBridgeScreenshot`).
254
+ const body = await this.postCommand(
255
+ {
256
+ type: 'bridge-screenshot',
257
+ refreshHiddenFrame: await this.preflightHidden(),
258
+ // Address THIS transport's instance so a per-seat `game.instance(id)`
259
+ // screenshot captures that seat's game stack, not always the primary.
260
+ ...(this.instance !== undefined ? { instance: this.instance } : {}),
261
+ },
262
+ SCREENSHOT_TIMEOUT_MS,
263
+ );
264
+ if (!body.ok || typeof body['base64'] !== 'string') {
265
+ throw new Error(
266
+ `vgai: screenshot unavailable — ${body.error ?? 'no play-mode canvas to capture'}`,
267
+ );
268
+ }
269
+ await mkdir(dirname(path), { recursive: true });
270
+ await writeFile(path, Buffer.from(body['base64'] as string, 'base64'));
271
+ // `game.screenshot()` hands back a path, so a near-blank frame is
272
+ // indistinguishable from a good one until somebody opens the file — which
273
+ // is exactly how two probes cited blank captures as evidence. The page
274
+ // wrote the sentence; say it where the caller is looking.
275
+ const warning = (body['flatness'] as { warning?: string } | undefined)?.warning;
276
+ if (typeof warning === 'string') console.warn(`vgai screenshot: warning — ${warning}`);
277
+ if (body['hiddenFrame'] === true) {
278
+ console.warn(
279
+ `vgai screenshot: ${path} is a HIDDEN FRAME — the editor tab is hidden, so the runtime ` +
280
+ 'rendered one deterministic tick on demand. It is current, not stale; it is not a ' +
281
+ 'frame anyone was watching.',
282
+ );
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Wave-2: ships `src` (`step.toString()`) to the editor dev server's
288
+ * `page-script` op — a STANDALONE relay command (like `bridge-screenshot`
289
+ * above), not a `bridge-call` method (see `command-listener.ts`'s
290
+ * `handlePageScript` doc comment for why). `step` itself is unused on this
291
+ * leg — closures don't survive the wire, see `bridge-transport.ts`'s
292
+ * `runPageScript` doc comment — kept only to satisfy the shared interface
293
+ * `PageTransport` (which DOES call it directly) also implements.
294
+ */
295
+ async runPageScript(src: string, _step: (page: unknown) => unknown): Promise<BridgeCallOutcome> {
296
+ try {
297
+ const body = await this.postCommand({ type: 'page-script', src }, PAGE_SCRIPT_TIMEOUT_MS);
298
+ return this.toBridgeOutcome(body);
299
+ } catch (err) {
300
+ return {
301
+ ok: false,
302
+ error: {
303
+ code: 'RELAY_UNREACHABLE',
304
+ message:
305
+ `vgai: could not reach the editor dev server relay at ${this.baseUrl} — ` +
306
+ `${err instanceof Error ? err.message : String(err)}`,
307
+ },
308
+ };
309
+ }
310
+ }
311
+ }