@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,91 @@
1
+ /**
2
+ * Where `GameClient.screenshot(x)` actually writes.
3
+ *
4
+ * WHY THIS IS ITS OWN MODULE (measured 2026-08-02, live session):
5
+ * `game.screenshot('.vgai/tmp/dragon/play-live.png')` — driven through
6
+ * `vgai eval`, the documented general door onto a running game — reported
7
+ * success and left NOTHING at the path the caller named. The argument was
8
+ * being read as a LABEL and run through `sanitizeLabel`, so the bytes landed
9
+ * at `<project>/.vgai/last-run/001--vgai-tmp-dragon-play-live-png.png`.
10
+ * Both artifacts are still on disk in the reproduction project. A call that
11
+ * reports success while the file the caller asked for does not exist is
12
+ * fabricated evidence — the exact failure mode `vgai screenshot` was built to
13
+ * prevent, reintroduced one layer down.
14
+ *
15
+ * The fix is to honour what the caller wrote. A LABEL ("waitfor-timeout") is
16
+ * a bare identifier: it keeps the numbered-artifact behaviour every run
17
+ * depends on. A PATH (anything with a separator, or any name carrying a file
18
+ * extension) is a destination: it is written EXACTLY there, relative paths
19
+ * resolved against the process cwd — the same rule `vgai screenshot --out`
20
+ * already documents.
21
+ */
22
+
23
+ import { isAbsolute, resolve } from 'node:path';
24
+
25
+ /** A caller's argument, classified. */
26
+ export type ScreenshotArgKind = 'path' | 'label';
27
+
28
+ /**
29
+ * PATH when the argument names a location: absolute, containing a `/` or `\`
30
+ * separator, an explicit `./`-style relative prefix, or carrying a file
31
+ * extension (`shot.png`). LABEL otherwise — the bare-identifier form specs
32
+ * pass (`'waitfor-timeout'`, `'events-expect-failure'`).
33
+ *
34
+ * The extension rule is what makes `screenshot('frame.png')` land at
35
+ * `./frame.png` instead of `.../001-frame-png.png`: a caller who typed an
36
+ * extension asked for a file, not a caption.
37
+ */
38
+ export function classifyScreenshotArg(arg: string): ScreenshotArgKind {
39
+ if (arg === '') return 'label';
40
+ if (isAbsolute(arg)) return 'path';
41
+ if (arg.includes('/') || arg.includes('\\')) return 'path';
42
+ if (/\.[a-zA-Z0-9]{1,8}$/.test(arg)) return 'path';
43
+ return 'label';
44
+ }
45
+
46
+ /** Non-filename characters collapse to `-` for the label form. */
47
+ export function sanitizeScreenshotLabel(label: string): string {
48
+ return label.replace(/[^a-zA-Z0-9-_]+/g, '-');
49
+ }
50
+
51
+ export interface ScreenshotTargetInput {
52
+ /** The caller's argument — a label or a path (see {@link classifyScreenshotArg}). */
53
+ readonly arg: string;
54
+ /** Artifacts directory for the label form. */
55
+ readonly artifactsDir: string;
56
+ /** 1-based ordinal for the label form's `NNN-` prefix. */
57
+ readonly sequence: number;
58
+ /** Base for resolving a relative path/artifactsDir — the process cwd. */
59
+ readonly cwd: string;
60
+ }
61
+
62
+ export interface ScreenshotTarget {
63
+ readonly kind: ScreenshotArgKind;
64
+ /** Absolute destination. */
65
+ readonly path: string;
66
+ /** True when the caller's own ordinal was consumed (label form only), so a
67
+ * path-form call never perturbs the numbering of the artifacts around it. */
68
+ readonly consumedSequence: boolean;
69
+ }
70
+
71
+ /**
72
+ * Resolve the absolute file a screenshot call must write. Pure — no I/O, no
73
+ * `process.cwd()` read — so the contract above is testable without a browser,
74
+ * a session, or a filesystem.
75
+ */
76
+ export function resolveScreenshotTarget(input: ScreenshotTargetInput): ScreenshotTarget {
77
+ const kind = classifyScreenshotArg(input.arg);
78
+ if (kind === 'path') {
79
+ const withExtension = /\.[a-zA-Z0-9]{1,8}$/.test(input.arg) ? input.arg : `${input.arg}.png`;
80
+ return {
81
+ kind,
82
+ path: isAbsolute(withExtension) ? withExtension : resolve(input.cwd, withExtension),
83
+ consumedSequence: false,
84
+ };
85
+ }
86
+ const fileName = `${String(input.sequence).padStart(3, '0')}-${sanitizeScreenshotLabel(input.arg)}.png`;
87
+ const dir = isAbsolute(input.artifactsDir)
88
+ ? input.artifactsDir
89
+ : resolve(input.cwd, input.artifactsDir);
90
+ return { kind, path: resolve(dir, fileName), consumedSequence: true };
91
+ }
@@ -0,0 +1,29 @@
1
+ /** Byte-capping for the failure block's "last state" member (Task 3.3: never
2
+ * elided, byte-capped at 4KB, truncation always marked). */
3
+
4
+ export interface CappedJson {
5
+ text: string;
6
+ truncated: boolean;
7
+ }
8
+
9
+ const TRUNCATION_MARKER = '\n… [truncated to fit the 4KB cap]';
10
+
11
+ export function capJson(value: unknown, maxBytes = 4096): CappedJson {
12
+ const full = JSON.stringify(value, null, 2) ?? 'undefined';
13
+ if (byteLength(full) <= maxBytes) {
14
+ return { text: full, truncated: false };
15
+ }
16
+ const budget = Math.max(maxBytes - byteLength(TRUNCATION_MARKER), 0);
17
+ let text = full;
18
+ // Binary-search-free shrink: strings only grow bytes via multi-byte UTF-8,
19
+ // so a length-proportional slice converges in a handful of iterations.
20
+ while (byteLength(text) > budget && text.length > 0) {
21
+ const ratio = budget / byteLength(text);
22
+ text = text.slice(0, Math.max(Math.floor(text.length * ratio), 0));
23
+ }
24
+ return { text: text + TRUNCATION_MARKER, truncated: true };
25
+ }
26
+
27
+ function byteLength(text: string): number {
28
+ return new TextEncoder().encode(text).length;
29
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The frozen shape of `window.__vgai`, the in-page debug bridge installed by
3
+ * the engine (`packages/engine/src/runtime/debug-bridge.ts`, Task 2.1 —
4
+ * landing concurrently with this package). This module declares that shape
5
+ * independently; it never imports the engine package, so this package can be
6
+ * built and tested independently of the bridge's landing.
7
+ */
8
+
9
+ export type ValueTier = 'observable' | 'assisted';
10
+
11
+ export interface ProviderInfo {
12
+ name: string;
13
+ tier: ValueTier;
14
+ }
15
+
16
+ export interface DebugCommandInfo {
17
+ name: string;
18
+ description?: string;
19
+ argsJsonSchema?: unknown;
20
+ locus: 'client' | 'server';
21
+ }
22
+
23
+ /** Run-4 friction #5: `seq` is a registry-lifetime monotonic counter (never
24
+ * reset, never shared by two events — unlike `tick`, which a debug-command
25
+ * emission and a fenced consumer's snapshot can legitimately collide on).
26
+ * Mirrors `@vgai/engine`'s `TickStampedEvent` (`adapter/system-adapter.ts`)
27
+ * — this package never imports the engine (see the module doc above), so
28
+ * the shape is declared here from the same contract. */
29
+ export interface TickStampedEvent {
30
+ tick: number;
31
+ simT: number;
32
+ event: string;
33
+ detail?: unknown;
34
+ seq: number;
35
+ }
36
+
37
+ export type VirtualActionValue = boolean | number | { x: number; y: number };
38
+
39
+ export interface VirtualActionResult {
40
+ delivered: boolean;
41
+ reason?: string;
42
+ }
43
+
44
+ export interface DebugBridgeInput {
45
+ setVirtualAction(action: string, value: VirtualActionValue): VirtualActionResult;
46
+ tapVirtualAction(action: string): VirtualActionResult;
47
+ clearVirtualActions(): void;
48
+ /** D15/T-D15.5 — schedule a virtual actuation for a specific tick, applied
49
+ * at the start of that tick's input phase. Declared here (Wave-2
50
+ * bridge↔wire coverage-parity gate) for type-shape completeness with
51
+ * `runtime/debug-bridge.ts`'s `VgaiDebugInputHandle` — this package still
52
+ * exposes no client-side convenience wrapper around it (deliberately
53
+ * parked; see `GameInput` in `client.ts`), this is pure type-shape
54
+ * mirroring. */
55
+ scheduleActionAtTick(tick: number, action: string, value: VirtualActionValue): void;
56
+ /** Wave-2 pointer-dispatch op — mirrors `runtime/debug-bridge.ts`'s
57
+ * `VgaiDebugInputHandle.injectPointerDelta`: accumulates a synthetic
58
+ * pointer delta for a named test source (sums within a frame, clears each
59
+ * frame). Declared here for type-shape completeness with the bridge, same
60
+ * precedent as `scheduleActionAtTick` above — no client-side convenience
61
+ * wrapper in `client.ts` (deliberately parked). */
62
+ injectPointerDelta(sourceId: string, delta: { x: number; y: number }): void;
63
+ /** Wave-2 pointer-dispatch op — mirrors `runtime/debug-bridge.ts`'s
64
+ * `VgaiDebugInputHandle.injectPointerPosition`: sets a synthetic absolute
65
+ * pointer position for a named test source (last-write-wins, persists
66
+ * until changed). Same type-shape-only precedent as `scheduleActionAtTick`. */
67
+ injectPointerPosition(sourceId: string, value: { x: number; y: number }): void;
68
+ }
69
+
70
+ /** D15/T-D15.4 door (a) — `Game.runTicks`'s options, mirrored from the
71
+ * engine's own `runtime/debug-registry.ts` `RunTicksOptions` (this package
72
+ * never imports the engine — see the module doc above — so the shape is
73
+ * declared here from the build plan's/D15 doc's contract text, same as
74
+ * every other bridge member). */
75
+ export interface RunTicksOptions {
76
+ render?: 'last' | 'all' | 'none';
77
+ }
78
+
79
+ /** One coherent read of the whole bridge — `snapshot()` is the fixture's poll
80
+ * primitive: every field comes from the same synchronous pass. */
81
+ export interface DebugSnapshot {
82
+ time: {
83
+ simSeconds: number;
84
+ tick: number;
85
+ /**
86
+ * Issue #175 — the REAL engine `GameLoop.liveness` behind this session
87
+ * (mirrors `@vgai/engine`'s `GameLoopLiveness`; this package never
88
+ * imports the engine — see the module doc above — so the union is
89
+ * declared here from the same contract). `'hidden-paused'` means the
90
+ * T2.1 idle throttle has stopped the loop because the tab is
91
+ * backgrounded: `simSeconds`/`tick` above are frozen, but this is NOT a
92
+ * crashed/wedged game — it resumes the instant the tab is foregrounded.
93
+ * `undefined` against an older bridge build that predates this field;
94
+ * `null` when the live bridge has no loop wired at all (should not
95
+ * happen against a real `Game`, but never fabricated either way).
96
+ */
97
+ loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null;
98
+ };
99
+ state: Record<string, unknown>;
100
+ events: TickStampedEvent[];
101
+ pageErrors: string[];
102
+ }
103
+
104
+ /** `window.__vgai`'s shape (version 1, frozen — see the build plan's ground
105
+ * rule 4). */
106
+ export interface VgaiBridgeHandle {
107
+ version: 1;
108
+ providers(): ProviderInfo[];
109
+ state(name: string): unknown;
110
+ stateAll(): Record<string, unknown>;
111
+ commands(): DebugCommandInfo[];
112
+ invoke(name: string, args: unknown[]): Promise<unknown>;
113
+ /** Run-4 friction #5: `sinceSeq`, when given, fences on
114
+ * `TickStampedEvent.seq` (unambiguous — see that field's doc comment). The
115
+ * old `sinceTick` fence (`tick > sinceTick`, which dropped any event
116
+ * sharing the fence's own tick) was REMOVED. */
117
+ events(sinceSeq?: number): TickStampedEvent[];
118
+ input: DebugBridgeInput;
119
+ snapshot(sinceSeq?: number): DebugSnapshot;
120
+ /** D15/T-D15.4 — see `RunTicksOptions`'s doc comment above. */
121
+ runTicks(n: number, opts?: RunTicksOptions): void;
122
+ /** Collapses `input.setVirtualAction(action, true)` → wait `simSeconds` of
123
+ * sim time (host-loop ticks while visible, deterministic same-phase ticks
124
+ * while hidden-paused) → `input.clearVirtualActions()` into one call — see
125
+ * `runtime/debug-bridge.ts`'s
126
+ * `holdFor` doc comment for the full contract (gated-immediately /
127
+ * play-stopped-mid-wait shapes). */
128
+ holdFor(action: string, simSeconds: number, worldId?: string): Promise<VirtualActionResult>;
129
+ }
130
+
131
+ // Deliberately no `declare global { interface Window { __vgai } }` here:
132
+ // the engine's own debug-bridge module (landing concurrently) is the real
133
+ // installer and may declare its own global augmentation for `window.__vgai`.
134
+ // Two independent ambient declarations of the same global member are only
135
+ // safe if structurally identical, and this package must not assume that —
136
+ // every access reaches through an explicit `window as { __vgai?: ... }` cast
137
+ // at the `page.evaluate()` boundary instead (see client.ts).
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Pure `game.waitFor` budget math — no Playwright, no browser. Polls a
3
+ * caller-supplied snapshot source and evaluates a predicate against ONE
4
+ * batched read per iteration (AC-B1.2: a predicate reading two providers
5
+ * mutated between polls never sees a mixed frame). Kept separate from
6
+ * `client.ts`'s Playwright wiring so it unit-tests headlessly (Task 3.2's
7
+ * architecture requirement).
8
+ */
9
+
10
+ import { SESSION_ERROR_CODES, SessionError } from './errors.js';
11
+ import type { DebugSnapshot } from './types.js';
12
+
13
+ export type WaitForBudget = { simSeconds: number } | { simTicks: number };
14
+
15
+ /** Byte-exact per Task 3.2 item: `waitFor`'s options type has no `timeout`
16
+ * key; this is the runtime guard for the mistake weaker models make anyway. */
17
+ export const WAIT_FOR_TIMEOUT_OPTION_MESSAGE =
18
+ 'waitFor takes { simSeconds } — budgets are sim-time (the game may run at 0.3x wall speed under SwiftShader); there is no wall-clock timeout here';
19
+
20
+ /**
21
+ * The teaching message for a budget passed POSITIONALLY —
22
+ * `game.waitSimTime(0.5)` instead of `game.waitSimTime({ simSeconds: 0.5 })`.
23
+ *
24
+ * That call used to be accepted in silence and was measured (blind build
25
+ * probe, 2026-08-06) doing the worst possible thing: `0.5` has no
26
+ * `simSeconds`, so the loop compares an elapsed delta against `undefined`,
27
+ * which is false forever. Against a HIDDEN tab — where the client drives
28
+ * deterministic ticks itself, so the frozen-clock stall guard never fires —
29
+ * the call simply never returns. Nothing is printed, nothing errors, and the
30
+ * caller is left with a wait that has nothing to do with their game.
31
+ *
32
+ * `describeBudgetArgument` names what actually arrived, because the whole
33
+ * failure is that the argument LOOKS reasonable.
34
+ */
35
+ export function positionalBudgetMessage(method: string, budget: unknown): string {
36
+ return (
37
+ `game.${method} takes an OPTIONS OBJECT and got ${describeBudgetArgument(budget)} — ` +
38
+ 'a positional budget is not read at all, so the wait never completes on its own. ' +
39
+ `Write it as: game.${method}({ simSeconds: 0.5 }) (or { simTicks: 30 }). ` +
40
+ 'Every binding and call in scope: vgai eval --list'
41
+ );
42
+ }
43
+
44
+ /** What actually arrived, for `positionalBudgetMessage` — a short, honest
45
+ * rendering rather than `[object Object]`/`undefined` ambiguity. */
46
+ function describeBudgetArgument(budget: unknown): string {
47
+ if (budget === null) return 'null';
48
+ if (budget === undefined) return 'no argument';
49
+ if (Array.isArray(budget)) return `an array (${JSON.stringify(budget)})`;
50
+ return `the ${typeof budget} ${JSON.stringify(budget) ?? String(budget)}`;
51
+ }
52
+
53
+ /** Throws (not merely a type error) if `budget` is not an options object at
54
+ * all, is missing both recognized keys, or carries a `timeout` key. Called
55
+ * before any polling starts. `method` names the call in the message, because
56
+ * this guard now fronts several of them (`waitFor`, `waitSimTime`,
57
+ * `fastForward`, `input.hold`) and an error naming the wrong one sends the
58
+ * reader to the wrong line.
59
+ * M8: throws the package's own `SessionError` carrying the frozen
60
+ * `WAIT_FOR_INVALID_BUDGET` code (not a bare `Error`) — every failure this
61
+ * package throws must carry a machine-readable code (see errors.ts's module
62
+ * doc); a bare `Error` here was the one place that rule was broken. */
63
+ export function assertValidWaitForBudget(
64
+ budget: unknown,
65
+ method = 'waitFor',
66
+ ): asserts budget is WaitForBudget {
67
+ const isObject = !!budget && typeof budget === 'object';
68
+ if (!isObject) {
69
+ throw new SessionError(
70
+ SESSION_ERROR_CODES.WAIT_FOR_INVALID_BUDGET,
71
+ positionalBudgetMessage(method, budget),
72
+ );
73
+ }
74
+ if ('timeout' in (budget as Record<string, unknown>)) {
75
+ throw new SessionError(
76
+ SESSION_ERROR_CODES.WAIT_FOR_INVALID_BUDGET,
77
+ WAIT_FOR_TIMEOUT_OPTION_MESSAGE,
78
+ );
79
+ }
80
+ const hasSimSeconds = 'simSeconds' in (budget as Record<string, unknown>);
81
+ const hasSimTicks = 'simTicks' in (budget as Record<string, unknown>);
82
+ if (!hasSimSeconds && !hasSimTicks) {
83
+ throw new SessionError(
84
+ SESSION_ERROR_CODES.WAIT_FOR_INVALID_BUDGET,
85
+ WAIT_FOR_TIMEOUT_OPTION_MESSAGE,
86
+ );
87
+ }
88
+ }
89
+
90
+ /** Sim-time delta the budget measures, `current` relative to `start`. */
91
+ export function budgetElapsed(
92
+ budget: WaitForBudget,
93
+ start: DebugSnapshot,
94
+ current: DebugSnapshot,
95
+ ): number {
96
+ return 'simSeconds' in budget
97
+ ? current.time.simSeconds - start.time.simSeconds
98
+ : current.time.tick - start.time.tick;
99
+ }
100
+
101
+ export function budgetTarget(budget: WaitForBudget): number {
102
+ return 'simSeconds' in budget ? budget.simSeconds : budget.simTicks;
103
+ }
104
+
105
+ export function budgetUnitLabel(budget: WaitForBudget): 'sim-seconds' | 'sim-ticks' {
106
+ return 'simSeconds' in budget ? 'sim-seconds' : 'sim-ticks';
107
+ }
108
+
109
+ /** Binds a snapshot to the synchronous `s(name)` reader predicates use, and
110
+ * (optionally) records which provider names the predicate actually read —
111
+ * the failure block's "assisted-tier consumed" header flag needs this. */
112
+ export function makeStateReader(
113
+ snapshot: DebugSnapshot,
114
+ touched?: Set<string>,
115
+ ): (name: string) => unknown {
116
+ return (name: string) => {
117
+ touched?.add(name);
118
+ return snapshot.state[name];
119
+ };
120
+ }
121
+
122
+ /** Everything the failure-block assembler needs about a timed-out waitFor.
123
+ * Deliberately does not know about providers/screenshots/console errors —
124
+ * those are gathered by the caller (client.ts) after catching this. */
125
+ export interface WaitForTimeoutInfo {
126
+ budget: WaitForBudget;
127
+ startSnapshot: DebugSnapshot;
128
+ lastSnapshot: DebugSnapshot;
129
+ wallElapsedMs: number;
130
+ predicateSource: string;
131
+ touchedProviders: string[];
132
+ }
133
+
134
+ export class WaitForTimeoutError extends Error {
135
+ readonly info: WaitForTimeoutInfo;
136
+ constructor(info: WaitForTimeoutInfo) {
137
+ super(
138
+ `game.waitFor timed out: budget ${budgetTarget(info.budget)} ${budgetUnitLabel(info.budget)}`,
139
+ );
140
+ this.name = 'WaitForTimeoutError';
141
+ this.info = info;
142
+ }
143
+ }
144
+
145
+ /** The clock/snapshot source `runWaitFor` polls against. Real usage (from
146
+ * `client.ts`) supplies a real `sleep`/`now` and a `snapshot()` that calls
147
+ * through to the page's bridge; tests supply scripted fakes. */
148
+ export interface WaitForClock {
149
+ snapshot(): Promise<DebugSnapshot> | DebugSnapshot;
150
+ now(): number;
151
+ sleep(ms: number): Promise<void>;
152
+ pollIntervalMs?: number;
153
+ /**
154
+ * Test-only safety valve bounding the number of polls, so a scripted
155
+ * "stalled" fake source terminates deterministically in unit tests.
156
+ * Real callers MUST leave this undefined: production `waitFor` has no
157
+ * internal wall-clock bound by design (the build plan forbids a
158
+ * wall-clock timeout PARAMETER) — a genuinely stalled game is instead
159
+ * caught by the outer harness (Playwright's own per-test timeout, or the
160
+ * caller's own outer budget), not by this
161
+ * module.
162
+ */
163
+ maxPolls?: number | undefined;
164
+ /** Real callers: `console.log`. Tests: a capturing fake, so heartbeat
165
+ * assertions never depend on stdout spies. Defaults to a no-op so a
166
+ * clock fixture that doesn't care about heartbeats needn't supply one. */
167
+ log?: (line: string) => void;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Fixture heartbeat (Wave-6 findings ledger: "Watchdog sim-awareness / fixture
172
+ // heartbeat for long silent tests" — Session C hit exit-5 on the runner's
173
+ // 2x90s stdout-liveness watchdog during a legitimately silent 5-minute test).
174
+ //
175
+ // A caller watching stdout for liveness already treats ANY
176
+ // child stdout as liveness — it has no idea what a line MEANS, only that one
177
+ // arrived. This is the fixture-side half: while `waitFor`/`waitSimTime` is
178
+ // polling, print one line every ~60s of WALL silence so a genuinely
179
+ // advancing test can never false-wedge regardless of duration, no matter how
180
+ // long a single `simSeconds` budget runs.
181
+ //
182
+ // INVARIANT (tested below and in fixture.test.ts): a heartbeat requires BOTH
183
+ // (a) >= HEARTBEAT_INTERVAL_MS of wall time since the last heartbeat, AND
184
+ // (b) the tick has ADVANCED since the last heartbeat (not merely since the
185
+ // last poll). Without (b), the poll loop itself — which keeps running
186
+ // against a frozen page, that being the whole reason `WAIT_FOR_STALL_POLL_
187
+ // LIMIT` above exists as a SEPARATE guard — would emit a heartbeat every 60s
188
+ // regardless of whether the game is actually alive, defeating the point: a
189
+ // frozen sim clock must go heartbeat-silent so the outer watchdog can still
190
+ // diagnose it as wedged. Advancing ticks -> heartbeats keep the run alive
191
+ // indefinitely; frozen ticks -> no heartbeats, and the existing stall guard
192
+ // (or the outer harness watchdog) still fires.
193
+ // ---------------------------------------------------------------------------
194
+
195
+ export const HEARTBEAT_INTERVAL_MS = 60_000;
196
+
197
+ /** Mutable-by-replacement heartbeat bookkeeping a caller threads through
198
+ * successive `maybeHeartbeat` calls — never mutated in place, so a test can
199
+ * freely compare successive states. */
200
+ export interface HeartbeatState {
201
+ lastEmitWallMs: number;
202
+ lastEmitTick: number;
203
+ }
204
+
205
+ /** The liveness line this prints to stdout — greppable, and
206
+ * stable so a human tailing a long run can `grep vgai-heartbeat`. */
207
+ export function formatHeartbeatLine(testTitle: string, simSeconds: number, tick: number): string {
208
+ return `vgai-heartbeat ${testTitle} simSeconds=${simSeconds} tick=${tick}`;
209
+ }
210
+
211
+ /**
212
+ * Pure decision, called once per poll: should a heartbeat print now, and
213
+ * what's the updated bookkeeping? See the module-doc invariant above — both
214
+ * the wall-silence budget AND tick advancement (since the last EMITTED
215
+ * heartbeat, not the last poll) must hold. Returns the SAME `state` object
216
+ * (referentially) when nothing should emit, so a caller can cheaply no-op.
217
+ */
218
+ export function maybeHeartbeat(opts: {
219
+ nowMs: number;
220
+ tick: number;
221
+ simSeconds: number;
222
+ testTitle: string;
223
+ state: HeartbeatState;
224
+ }): { line: string | null; state: HeartbeatState } {
225
+ const wallElapsed = opts.nowMs - opts.state.lastEmitWallMs;
226
+ const tickAdvancedSinceLastEmit = opts.tick !== opts.state.lastEmitTick;
227
+ if (wallElapsed >= HEARTBEAT_INTERVAL_MS && tickAdvancedSinceLastEmit) {
228
+ return {
229
+ line: formatHeartbeatLine(opts.testTitle, opts.simSeconds, opts.tick),
230
+ state: { lastEmitWallMs: opts.nowMs, lastEmitTick: opts.tick },
231
+ };
232
+ }
233
+ return { line: null, state: opts.state };
234
+ }
235
+
236
+ /**
237
+ * D2: a real internal stall guard, always active (unlike `maxPolls` above,
238
+ * which is a test-only seam real callers must leave unset). The bug this
239
+ * fixes: `budgetElapsed` (above) is computed from `current.time.simSeconds`/
240
+ * `.tick` — if the game's sim clock freezes entirely (the loop itself is
241
+ * stalled, not merely backgrounded), those fields never change, so
242
+ * `budgetElapsed` stays at 0 FOREVER and the budget itself can never
243
+ * exhaust — contradicting hidden-recovery.ts's own doc comment, which
244
+ * assumes `runWaitFor`'s sim-time budget is what eventually diagnoses a
245
+ * still-stalled loop after `HiddenRecoveryDriver` has had its one
246
+ * `bringToFront()` chance.
247
+ *
248
+ * 200 consecutive polls with an utterly unchanged tick, at the default
249
+ * 150ms poll interval, is ~30s of real wall time — comfortably (20x) past
250
+ * `HIDDEN_RECOVERY_STALL_POLLS`'s ~1.5s window (hidden-recovery.ts), so a
251
+ * merely-backgrounded tab has already had its recovery chance well before
252
+ * this guard would ever fire. If the tick is STILL frozen after that much
253
+ * wall time, the game loop itself is stalled (not the tab), and this guard
254
+ * throws the same `WaitForTimeoutError` an ordinary budget exhaustion would
255
+ * — the failure block renders honestly, with its ~0x sim-speed ratio and
256
+ * "if ~0x, the loop is stalled" hint (failure-block.ts), rather than the
257
+ * caller hanging forever.
258
+ */
259
+ export const WAIT_FOR_STALL_POLL_LIMIT = 200;
260
+
261
+ /**
262
+ * Polls `clock.snapshot()` until `pred` is true or `budget` is exhausted.
263
+ * Each iteration reads exactly one snapshot (AC-B1.2). On exhaustion throws
264
+ * `WaitForTimeoutError` carrying everything `client.ts` needs to assemble
265
+ * the full failure block.
266
+ *
267
+ * `testTitle` (defaults to `'test'` for callers that don't have one — every
268
+ * REAL caller, `client.ts`'s `waitFor`, always supplies the real Playwright
269
+ * test title) names the fixture heartbeat lines this loop emits — see the
270
+ * module doc above `maybeHeartbeat` for the emission invariant.
271
+ */
272
+ export async function runWaitFor(
273
+ pred: (s: (name: string) => unknown) => boolean,
274
+ budget: WaitForBudget,
275
+ clock: WaitForClock,
276
+ testTitle = 'test',
277
+ ): Promise<DebugSnapshot> {
278
+ assertValidWaitForBudget(budget);
279
+ const pollIntervalMs = clock.pollIntervalMs ?? 150;
280
+ const log = clock.log ?? (() => {});
281
+ const startWall = clock.now();
282
+ const start = await clock.snapshot();
283
+ let current = start;
284
+ const touched = new Set<string>();
285
+ let polls = 0;
286
+ // D2: consecutive polls (so far) whose tick exactly matches the poll
287
+ // before it — independent of the budget's own elapsed math, which is what
288
+ // lets this catch a frozen clock the budget itself would never exhaust.
289
+ let stalledTickPolls = 0;
290
+ let heartbeat: HeartbeatState = { lastEmitWallMs: startWall, lastEmitTick: start.time.tick };
291
+
292
+ for (;;) {
293
+ const reader = makeStateReader(current, touched);
294
+ if (pred(reader)) return current;
295
+
296
+ polls += 1;
297
+ const elapsed = budgetElapsed(budget, start, current);
298
+ const target = budgetTarget(budget);
299
+ const outOfPolls = clock.maxPolls !== undefined && polls >= clock.maxPolls;
300
+ const stalled = stalledTickPolls >= WAIT_FOR_STALL_POLL_LIMIT;
301
+ if (elapsed >= target || outOfPolls || stalled) {
302
+ throw new WaitForTimeoutError({
303
+ budget,
304
+ startSnapshot: start,
305
+ lastSnapshot: current,
306
+ wallElapsedMs: clock.now() - startWall,
307
+ predicateSource: pred.toString(),
308
+ touchedProviders: [...touched],
309
+ });
310
+ }
311
+
312
+ await clock.sleep(pollIntervalMs);
313
+ const next = await clock.snapshot();
314
+ stalledTickPolls = next.time.tick === current.time.tick ? stalledTickPolls + 1 : 0;
315
+ current = next;
316
+
317
+ const heartbeatResult = maybeHeartbeat({
318
+ nowMs: clock.now(),
319
+ tick: next.time.tick,
320
+ simSeconds: next.time.simSeconds,
321
+ testTitle,
322
+ state: heartbeat,
323
+ });
324
+ heartbeat = heartbeatResult.state;
325
+ if (heartbeatResult.line) log(heartbeatResult.line);
326
+ }
327
+ }