@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,155 @@
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
+ import type { DebugSnapshot } from './types.js';
10
+ export type WaitForBudget = {
11
+ simSeconds: number;
12
+ } | {
13
+ 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 declare const WAIT_FOR_TIMEOUT_OPTION_MESSAGE = "waitFor takes { simSeconds } \u2014 budgets are sim-time (the game may run at 0.3x wall speed under SwiftShader); there is no wall-clock timeout here";
18
+ /**
19
+ * The teaching message for a budget passed POSITIONALLY —
20
+ * `game.waitSimTime(0.5)` instead of `game.waitSimTime({ simSeconds: 0.5 })`.
21
+ *
22
+ * That call used to be accepted in silence and was measured (blind build
23
+ * probe, 2026-08-06) doing the worst possible thing: `0.5` has no
24
+ * `simSeconds`, so the loop compares an elapsed delta against `undefined`,
25
+ * which is false forever. Against a HIDDEN tab — where the client drives
26
+ * deterministic ticks itself, so the frozen-clock stall guard never fires —
27
+ * the call simply never returns. Nothing is printed, nothing errors, and the
28
+ * caller is left with a wait that has nothing to do with their game.
29
+ *
30
+ * `describeBudgetArgument` names what actually arrived, because the whole
31
+ * failure is that the argument LOOKS reasonable.
32
+ */
33
+ export declare function positionalBudgetMessage(method: string, budget: unknown): string;
34
+ /** Throws (not merely a type error) if `budget` is not an options object at
35
+ * all, is missing both recognized keys, or carries a `timeout` key. Called
36
+ * before any polling starts. `method` names the call in the message, because
37
+ * this guard now fronts several of them (`waitFor`, `waitSimTime`,
38
+ * `fastForward`, `input.hold`) and an error naming the wrong one sends the
39
+ * reader to the wrong line.
40
+ * M8: throws the package's own `SessionError` carrying the frozen
41
+ * `WAIT_FOR_INVALID_BUDGET` code (not a bare `Error`) — every failure this
42
+ * package throws must carry a machine-readable code (see errors.ts's module
43
+ * doc); a bare `Error` here was the one place that rule was broken. */
44
+ export declare function assertValidWaitForBudget(budget: unknown, method?: string): asserts budget is WaitForBudget;
45
+ /** Sim-time delta the budget measures, `current` relative to `start`. */
46
+ export declare function budgetElapsed(budget: WaitForBudget, start: DebugSnapshot, current: DebugSnapshot): number;
47
+ export declare function budgetTarget(budget: WaitForBudget): number;
48
+ export declare function budgetUnitLabel(budget: WaitForBudget): 'sim-seconds' | 'sim-ticks';
49
+ /** Binds a snapshot to the synchronous `s(name)` reader predicates use, and
50
+ * (optionally) records which provider names the predicate actually read —
51
+ * the failure block's "assisted-tier consumed" header flag needs this. */
52
+ export declare function makeStateReader(snapshot: DebugSnapshot, touched?: Set<string>): (name: string) => unknown;
53
+ /** Everything the failure-block assembler needs about a timed-out waitFor.
54
+ * Deliberately does not know about providers/screenshots/console errors —
55
+ * those are gathered by the caller (client.ts) after catching this. */
56
+ export interface WaitForTimeoutInfo {
57
+ budget: WaitForBudget;
58
+ startSnapshot: DebugSnapshot;
59
+ lastSnapshot: DebugSnapshot;
60
+ wallElapsedMs: number;
61
+ predicateSource: string;
62
+ touchedProviders: string[];
63
+ }
64
+ export declare class WaitForTimeoutError extends Error {
65
+ readonly info: WaitForTimeoutInfo;
66
+ constructor(info: WaitForTimeoutInfo);
67
+ }
68
+ /** The clock/snapshot source `runWaitFor` polls against. Real usage (from
69
+ * `client.ts`) supplies a real `sleep`/`now` and a `snapshot()` that calls
70
+ * through to the page's bridge; tests supply scripted fakes. */
71
+ export interface WaitForClock {
72
+ snapshot(): Promise<DebugSnapshot> | DebugSnapshot;
73
+ now(): number;
74
+ sleep(ms: number): Promise<void>;
75
+ pollIntervalMs?: number;
76
+ /**
77
+ * Test-only safety valve bounding the number of polls, so a scripted
78
+ * "stalled" fake source terminates deterministically in unit tests.
79
+ * Real callers MUST leave this undefined: production `waitFor` has no
80
+ * internal wall-clock bound by design (the build plan forbids a
81
+ * wall-clock timeout PARAMETER) — a genuinely stalled game is instead
82
+ * caught by the outer harness (Playwright's own per-test timeout, or the
83
+ * caller's own outer budget), not by this
84
+ * module.
85
+ */
86
+ maxPolls?: number | undefined;
87
+ /** Real callers: `console.log`. Tests: a capturing fake, so heartbeat
88
+ * assertions never depend on stdout spies. Defaults to a no-op so a
89
+ * clock fixture that doesn't care about heartbeats needn't supply one. */
90
+ log?: (line: string) => void;
91
+ }
92
+ export declare const HEARTBEAT_INTERVAL_MS = 60000;
93
+ /** Mutable-by-replacement heartbeat bookkeeping a caller threads through
94
+ * successive `maybeHeartbeat` calls — never mutated in place, so a test can
95
+ * freely compare successive states. */
96
+ export interface HeartbeatState {
97
+ lastEmitWallMs: number;
98
+ lastEmitTick: number;
99
+ }
100
+ /** The liveness line this prints to stdout — greppable, and
101
+ * stable so a human tailing a long run can `grep vgai-heartbeat`. */
102
+ export declare function formatHeartbeatLine(testTitle: string, simSeconds: number, tick: number): string;
103
+ /**
104
+ * Pure decision, called once per poll: should a heartbeat print now, and
105
+ * what's the updated bookkeeping? See the module-doc invariant above — both
106
+ * the wall-silence budget AND tick advancement (since the last EMITTED
107
+ * heartbeat, not the last poll) must hold. Returns the SAME `state` object
108
+ * (referentially) when nothing should emit, so a caller can cheaply no-op.
109
+ */
110
+ export declare function maybeHeartbeat(opts: {
111
+ nowMs: number;
112
+ tick: number;
113
+ simSeconds: number;
114
+ testTitle: string;
115
+ state: HeartbeatState;
116
+ }): {
117
+ line: string | null;
118
+ state: HeartbeatState;
119
+ };
120
+ /**
121
+ * D2: a real internal stall guard, always active (unlike `maxPolls` above,
122
+ * which is a test-only seam real callers must leave unset). The bug this
123
+ * fixes: `budgetElapsed` (above) is computed from `current.time.simSeconds`/
124
+ * `.tick` — if the game's sim clock freezes entirely (the loop itself is
125
+ * stalled, not merely backgrounded), those fields never change, so
126
+ * `budgetElapsed` stays at 0 FOREVER and the budget itself can never
127
+ * exhaust — contradicting hidden-recovery.ts's own doc comment, which
128
+ * assumes `runWaitFor`'s sim-time budget is what eventually diagnoses a
129
+ * still-stalled loop after `HiddenRecoveryDriver` has had its one
130
+ * `bringToFront()` chance.
131
+ *
132
+ * 200 consecutive polls with an utterly unchanged tick, at the default
133
+ * 150ms poll interval, is ~30s of real wall time — comfortably (20x) past
134
+ * `HIDDEN_RECOVERY_STALL_POLLS`'s ~1.5s window (hidden-recovery.ts), so a
135
+ * merely-backgrounded tab has already had its recovery chance well before
136
+ * this guard would ever fire. If the tick is STILL frozen after that much
137
+ * wall time, the game loop itself is stalled (not the tab), and this guard
138
+ * throws the same `WaitForTimeoutError` an ordinary budget exhaustion would
139
+ * — the failure block renders honestly, with its ~0x sim-speed ratio and
140
+ * "if ~0x, the loop is stalled" hint (failure-block.ts), rather than the
141
+ * caller hanging forever.
142
+ */
143
+ export declare const WAIT_FOR_STALL_POLL_LIMIT = 200;
144
+ /**
145
+ * Polls `clock.snapshot()` until `pred` is true or `budget` is exhausted.
146
+ * Each iteration reads exactly one snapshot (AC-B1.2). On exhaustion throws
147
+ * `WaitForTimeoutError` carrying everything `client.ts` needs to assemble
148
+ * the full failure block.
149
+ *
150
+ * `testTitle` (defaults to `'test'` for callers that don't have one — every
151
+ * REAL caller, `client.ts`'s `waitFor`, always supplies the real Playwright
152
+ * test title) names the fixture heartbeat lines this loop emits — see the
153
+ * module doc above `maybeHeartbeat` for the emission invariant.
154
+ */
155
+ export declare function runWaitFor(pred: (s: (name: string) => unknown) => boolean, budget: WaitForBudget, clock: WaitForClock, testTitle?: string): Promise<DebugSnapshot>;
@@ -0,0 +1,229 @@
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
+ import { SESSION_ERROR_CODES, SessionError } from './errors.js';
10
+ /** Byte-exact per Task 3.2 item: `waitFor`'s options type has no `timeout`
11
+ * key; this is the runtime guard for the mistake weaker models make anyway. */
12
+ export const WAIT_FOR_TIMEOUT_OPTION_MESSAGE = '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';
13
+ /**
14
+ * The teaching message for a budget passed POSITIONALLY —
15
+ * `game.waitSimTime(0.5)` instead of `game.waitSimTime({ simSeconds: 0.5 })`.
16
+ *
17
+ * That call used to be accepted in silence and was measured (blind build
18
+ * probe, 2026-08-06) doing the worst possible thing: `0.5` has no
19
+ * `simSeconds`, so the loop compares an elapsed delta against `undefined`,
20
+ * which is false forever. Against a HIDDEN tab — where the client drives
21
+ * deterministic ticks itself, so the frozen-clock stall guard never fires —
22
+ * the call simply never returns. Nothing is printed, nothing errors, and the
23
+ * caller is left with a wait that has nothing to do with their game.
24
+ *
25
+ * `describeBudgetArgument` names what actually arrived, because the whole
26
+ * failure is that the argument LOOKS reasonable.
27
+ */
28
+ export function positionalBudgetMessage(method, budget) {
29
+ return (`game.${method} takes an OPTIONS OBJECT and got ${describeBudgetArgument(budget)} — ` +
30
+ 'a positional budget is not read at all, so the wait never completes on its own. ' +
31
+ `Write it as: game.${method}({ simSeconds: 0.5 }) (or { simTicks: 30 }). ` +
32
+ 'Every binding and call in scope: vgai eval --list');
33
+ }
34
+ /** What actually arrived, for `positionalBudgetMessage` — a short, honest
35
+ * rendering rather than `[object Object]`/`undefined` ambiguity. */
36
+ function describeBudgetArgument(budget) {
37
+ if (budget === null)
38
+ return 'null';
39
+ if (budget === undefined)
40
+ return 'no argument';
41
+ if (Array.isArray(budget))
42
+ return `an array (${JSON.stringify(budget)})`;
43
+ return `the ${typeof budget} ${JSON.stringify(budget) ?? String(budget)}`;
44
+ }
45
+ /** Throws (not merely a type error) if `budget` is not an options object at
46
+ * all, is missing both recognized keys, or carries a `timeout` key. Called
47
+ * before any polling starts. `method` names the call in the message, because
48
+ * this guard now fronts several of them (`waitFor`, `waitSimTime`,
49
+ * `fastForward`, `input.hold`) and an error naming the wrong one sends the
50
+ * reader to the wrong line.
51
+ * M8: throws the package's own `SessionError` carrying the frozen
52
+ * `WAIT_FOR_INVALID_BUDGET` code (not a bare `Error`) — every failure this
53
+ * package throws must carry a machine-readable code (see errors.ts's module
54
+ * doc); a bare `Error` here was the one place that rule was broken. */
55
+ export function assertValidWaitForBudget(budget, method = 'waitFor') {
56
+ const isObject = !!budget && typeof budget === 'object';
57
+ if (!isObject) {
58
+ throw new SessionError(SESSION_ERROR_CODES.WAIT_FOR_INVALID_BUDGET, positionalBudgetMessage(method, budget));
59
+ }
60
+ if ('timeout' in budget) {
61
+ throw new SessionError(SESSION_ERROR_CODES.WAIT_FOR_INVALID_BUDGET, WAIT_FOR_TIMEOUT_OPTION_MESSAGE);
62
+ }
63
+ const hasSimSeconds = 'simSeconds' in budget;
64
+ const hasSimTicks = 'simTicks' in budget;
65
+ if (!hasSimSeconds && !hasSimTicks) {
66
+ throw new SessionError(SESSION_ERROR_CODES.WAIT_FOR_INVALID_BUDGET, WAIT_FOR_TIMEOUT_OPTION_MESSAGE);
67
+ }
68
+ }
69
+ /** Sim-time delta the budget measures, `current` relative to `start`. */
70
+ export function budgetElapsed(budget, start, current) {
71
+ return 'simSeconds' in budget
72
+ ? current.time.simSeconds - start.time.simSeconds
73
+ : current.time.tick - start.time.tick;
74
+ }
75
+ export function budgetTarget(budget) {
76
+ return 'simSeconds' in budget ? budget.simSeconds : budget.simTicks;
77
+ }
78
+ export function budgetUnitLabel(budget) {
79
+ return 'simSeconds' in budget ? 'sim-seconds' : 'sim-ticks';
80
+ }
81
+ /** Binds a snapshot to the synchronous `s(name)` reader predicates use, and
82
+ * (optionally) records which provider names the predicate actually read —
83
+ * the failure block's "assisted-tier consumed" header flag needs this. */
84
+ export function makeStateReader(snapshot, touched) {
85
+ return (name) => {
86
+ touched?.add(name);
87
+ return snapshot.state[name];
88
+ };
89
+ }
90
+ export class WaitForTimeoutError extends Error {
91
+ info;
92
+ constructor(info) {
93
+ super(`game.waitFor timed out: budget ${budgetTarget(info.budget)} ${budgetUnitLabel(info.budget)}`);
94
+ this.name = 'WaitForTimeoutError';
95
+ this.info = info;
96
+ }
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Fixture heartbeat (Wave-6 findings ledger: "Watchdog sim-awareness / fixture
100
+ // heartbeat for long silent tests" — Session C hit exit-5 on the runner's
101
+ // 2x90s stdout-liveness watchdog during a legitimately silent 5-minute test).
102
+ //
103
+ // A caller watching stdout for liveness already treats ANY
104
+ // child stdout as liveness — it has no idea what a line MEANS, only that one
105
+ // arrived. This is the fixture-side half: while `waitFor`/`waitSimTime` is
106
+ // polling, print one line every ~60s of WALL silence so a genuinely
107
+ // advancing test can never false-wedge regardless of duration, no matter how
108
+ // long a single `simSeconds` budget runs.
109
+ //
110
+ // INVARIANT (tested below and in fixture.test.ts): a heartbeat requires BOTH
111
+ // (a) >= HEARTBEAT_INTERVAL_MS of wall time since the last heartbeat, AND
112
+ // (b) the tick has ADVANCED since the last heartbeat (not merely since the
113
+ // last poll). Without (b), the poll loop itself — which keeps running
114
+ // against a frozen page, that being the whole reason `WAIT_FOR_STALL_POLL_
115
+ // LIMIT` above exists as a SEPARATE guard — would emit a heartbeat every 60s
116
+ // regardless of whether the game is actually alive, defeating the point: a
117
+ // frozen sim clock must go heartbeat-silent so the outer watchdog can still
118
+ // diagnose it as wedged. Advancing ticks -> heartbeats keep the run alive
119
+ // indefinitely; frozen ticks -> no heartbeats, and the existing stall guard
120
+ // (or the outer harness watchdog) still fires.
121
+ // ---------------------------------------------------------------------------
122
+ export const HEARTBEAT_INTERVAL_MS = 60_000;
123
+ /** The liveness line this prints to stdout — greppable, and
124
+ * stable so a human tailing a long run can `grep vgai-heartbeat`. */
125
+ export function formatHeartbeatLine(testTitle, simSeconds, tick) {
126
+ return `vgai-heartbeat ${testTitle} simSeconds=${simSeconds} tick=${tick}`;
127
+ }
128
+ /**
129
+ * Pure decision, called once per poll: should a heartbeat print now, and
130
+ * what's the updated bookkeeping? See the module-doc invariant above — both
131
+ * the wall-silence budget AND tick advancement (since the last EMITTED
132
+ * heartbeat, not the last poll) must hold. Returns the SAME `state` object
133
+ * (referentially) when nothing should emit, so a caller can cheaply no-op.
134
+ */
135
+ export function maybeHeartbeat(opts) {
136
+ const wallElapsed = opts.nowMs - opts.state.lastEmitWallMs;
137
+ const tickAdvancedSinceLastEmit = opts.tick !== opts.state.lastEmitTick;
138
+ if (wallElapsed >= HEARTBEAT_INTERVAL_MS && tickAdvancedSinceLastEmit) {
139
+ return {
140
+ line: formatHeartbeatLine(opts.testTitle, opts.simSeconds, opts.tick),
141
+ state: { lastEmitWallMs: opts.nowMs, lastEmitTick: opts.tick },
142
+ };
143
+ }
144
+ return { line: null, state: opts.state };
145
+ }
146
+ /**
147
+ * D2: a real internal stall guard, always active (unlike `maxPolls` above,
148
+ * which is a test-only seam real callers must leave unset). The bug this
149
+ * fixes: `budgetElapsed` (above) is computed from `current.time.simSeconds`/
150
+ * `.tick` — if the game's sim clock freezes entirely (the loop itself is
151
+ * stalled, not merely backgrounded), those fields never change, so
152
+ * `budgetElapsed` stays at 0 FOREVER and the budget itself can never
153
+ * exhaust — contradicting hidden-recovery.ts's own doc comment, which
154
+ * assumes `runWaitFor`'s sim-time budget is what eventually diagnoses a
155
+ * still-stalled loop after `HiddenRecoveryDriver` has had its one
156
+ * `bringToFront()` chance.
157
+ *
158
+ * 200 consecutive polls with an utterly unchanged tick, at the default
159
+ * 150ms poll interval, is ~30s of real wall time — comfortably (20x) past
160
+ * `HIDDEN_RECOVERY_STALL_POLLS`'s ~1.5s window (hidden-recovery.ts), so a
161
+ * merely-backgrounded tab has already had its recovery chance well before
162
+ * this guard would ever fire. If the tick is STILL frozen after that much
163
+ * wall time, the game loop itself is stalled (not the tab), and this guard
164
+ * throws the same `WaitForTimeoutError` an ordinary budget exhaustion would
165
+ * — the failure block renders honestly, with its ~0x sim-speed ratio and
166
+ * "if ~0x, the loop is stalled" hint (failure-block.ts), rather than the
167
+ * caller hanging forever.
168
+ */
169
+ export const WAIT_FOR_STALL_POLL_LIMIT = 200;
170
+ /**
171
+ * Polls `clock.snapshot()` until `pred` is true or `budget` is exhausted.
172
+ * Each iteration reads exactly one snapshot (AC-B1.2). On exhaustion throws
173
+ * `WaitForTimeoutError` carrying everything `client.ts` needs to assemble
174
+ * the full failure block.
175
+ *
176
+ * `testTitle` (defaults to `'test'` for callers that don't have one — every
177
+ * REAL caller, `client.ts`'s `waitFor`, always supplies the real Playwright
178
+ * test title) names the fixture heartbeat lines this loop emits — see the
179
+ * module doc above `maybeHeartbeat` for the emission invariant.
180
+ */
181
+ export async function runWaitFor(pred, budget, clock, testTitle = 'test') {
182
+ assertValidWaitForBudget(budget);
183
+ const pollIntervalMs = clock.pollIntervalMs ?? 150;
184
+ const log = clock.log ?? (() => { });
185
+ const startWall = clock.now();
186
+ const start = await clock.snapshot();
187
+ let current = start;
188
+ const touched = new Set();
189
+ let polls = 0;
190
+ // D2: consecutive polls (so far) whose tick exactly matches the poll
191
+ // before it — independent of the budget's own elapsed math, which is what
192
+ // lets this catch a frozen clock the budget itself would never exhaust.
193
+ let stalledTickPolls = 0;
194
+ let heartbeat = { lastEmitWallMs: startWall, lastEmitTick: start.time.tick };
195
+ for (;;) {
196
+ const reader = makeStateReader(current, touched);
197
+ if (pred(reader))
198
+ return current;
199
+ polls += 1;
200
+ const elapsed = budgetElapsed(budget, start, current);
201
+ const target = budgetTarget(budget);
202
+ const outOfPolls = clock.maxPolls !== undefined && polls >= clock.maxPolls;
203
+ const stalled = stalledTickPolls >= WAIT_FOR_STALL_POLL_LIMIT;
204
+ if (elapsed >= target || outOfPolls || stalled) {
205
+ throw new WaitForTimeoutError({
206
+ budget,
207
+ startSnapshot: start,
208
+ lastSnapshot: current,
209
+ wallElapsedMs: clock.now() - startWall,
210
+ predicateSource: pred.toString(),
211
+ touchedProviders: [...touched],
212
+ });
213
+ }
214
+ await clock.sleep(pollIntervalMs);
215
+ const next = await clock.snapshot();
216
+ stalledTickPolls = next.time.tick === current.time.tick ? stalledTickPolls + 1 : 0;
217
+ current = next;
218
+ const heartbeatResult = maybeHeartbeat({
219
+ nowMs: clock.now(),
220
+ tick: next.time.tick,
221
+ simSeconds: next.time.simSeconds,
222
+ testTitle,
223
+ state: heartbeat,
224
+ });
225
+ heartbeat = heartbeatResult.state;
226
+ if (heartbeatResult.line)
227
+ log(heartbeatResult.line);
228
+ }
229
+ }
package/dist/game.d.ts CHANGED
@@ -1,29 +1,58 @@
1
1
  /**
2
2
  * `game` — the game-control half of `@vgai/live`'s `{ editor, game, page }`.
3
- * A `GameClient` (`@vgai/e2e`) bound to a `RelayTransport` on the resolved
3
+ * A `GameClient` (`./game-client/`) bound to a `RelayTransport` on the resolved
4
4
  * session's port — the SAME session-wire relay (`POST /__editor/command`,
5
5
  * `bridge-call`/`bridge-screenshot`/`page-script`) every `EditorClient`
6
6
  * method already uses. Never forked: this module only WIRES `GameClient` up,
7
7
  * it does not reimplement any of its methods (`state`/`waitFor`/`events`/
8
8
  * `input.hold`/`input.tap`/`screenshot`/`command`/`page` — whatever
9
9
  * `GameClient` exposes is exposed here, unchanged).
10
- */
11
- import { GameClient } from '@vgai/e2e';
12
- /**
13
- * Builds a `GameClient` over a `RelayTransport({ port })`.
14
10
  *
15
- * Fence values (`fenceTick`/`fenceSeq`/`fenceSimSeconds`/`fenceWallMs`) are
16
- * placeholders tick 0, seq 0, sim-seconds 0, "now" rather than a real
17
- * play-mode-start snapshot. `@vgai/e2e`'s own relay fixture
18
- * (`relay-fixture.ts`'s `waitForRelayBridge`) waits for a FRESH play-mode
19
- * boot before constructing its `game` client, because a test always starts
20
- * from a known instant. A `connect()`ed live session has no such instant
21
- * it may attach to an ALREADY-RUNNING game session — so there is no single
22
- * "test start" to fence from. Practical effect: `game.events.expect(...)`
23
- * sees the WHOLE event history since play mode started (not a per-call
24
- * window); every other method (`state`/`waitFor`/`input`/`command`/
25
- * `screenshot`) is unaffected by the fence. `pageErrors`/`consoleErrors` are
26
- * always empty — relay mode has no separate page handle to listen on (the
27
- * same documented gap `relay-fixture.ts` carries).
11
+ * When the editor has MORE THAN ONE instance mounted (multiplayer authoring —
12
+ * see the engine's play-mode `mountAdditionalInstance`), `game` alone REFUSES
13
+ * to guess which one a call addresses. `game.instances()` / `game.instance(id)`
14
+ * are the way past that: each returns a `GameClient` whose relay carries a
15
+ * specific mount id (`RelayTransport({ instance })`), so one script can drive
16
+ * several instances two seats of a match, or one scene under two seeds
17
+ * without ambiguity.
28
18
  */
19
+ import { GameClient } from './game-client/index.js';
20
+ /** A `GameClient` addressing ONE mounted instance, tagged with the mount `id`
21
+ * it drives. `id` is the whole point of the handle to a CALLER: it is what
22
+ * `tools.run(name, args, { instance: handle.id })` passes to scope a
23
+ * node-hosted tool (e.g. `project.autoplay`) to this seat — the two-seat
24
+ * multiplayer verification pattern. Without it the caller has a client it can
25
+ * drive but no id to hand a tool, so the seat-scoped path silently degrades to
26
+ * the sole-instance one. */
27
+ export type AddressedGameClient = GameClient & {
28
+ readonly id: string;
29
+ };
30
+ /** `game` plus the instance-addressing surface (see this module's doc). The
31
+ * base `GameClient` methods are unchanged; a bare call still targets the sole
32
+ * instance and refuses when several are live. */
33
+ export interface LiveGame extends GameClient {
34
+ /**
35
+ * A handle addressing ONE mounted instance by its mount id — a `GameClient`
36
+ * whose every relay call carries `instance: id`, and which exposes that `id`
37
+ * back (`handle.id === id`). Synchronous: it builds a client, it does not
38
+ * contact the editor. (Through the bare top-level `game` singleton it
39
+ * resolves a session first and so returns a promise — the same proxy
40
+ * limitation the singleton documents; the `connect()`/`vgai eval` form is
41
+ * synchronous.)
42
+ */
43
+ instance(id: string): AddressedGameClient;
44
+ /**
45
+ * Handles for every instance currently mounted, in mount order — each tagged
46
+ * with its own `id`. One round trip (`list-instances`) to enumerate, then one
47
+ * addressed `GameClient` per id. `[]` when nothing is mounted (not playing) —
48
+ * the honest answer.
49
+ */
50
+ instances(): Promise<AddressedGameClient[]>;
51
+ }
52
+ /** The unaddressed `game` client — targets the sole live instance and refuses
53
+ * when several are mounted. Kept as a named export for callers/tests that
54
+ * want just the base client. */
29
55
  export declare function createGameClient(port: number, artifactsDir?: string): GameClient;
56
+ /** Build the `LiveGame` — the base `game` client plus its instance-addressing
57
+ * surface. */
58
+ export declare function createLiveGame(port: number, artifactsDir?: string): LiveGame;
package/dist/game.js CHANGED
@@ -1,34 +1,40 @@
1
1
  /**
2
2
  * `game` — the game-control half of `@vgai/live`'s `{ editor, game, page }`.
3
- * A `GameClient` (`@vgai/e2e`) bound to a `RelayTransport` on the resolved
3
+ * A `GameClient` (`./game-client/`) bound to a `RelayTransport` on the resolved
4
4
  * session's port — the SAME session-wire relay (`POST /__editor/command`,
5
5
  * `bridge-call`/`bridge-screenshot`/`page-script`) every `EditorClient`
6
6
  * method already uses. Never forked: this module only WIRES `GameClient` up,
7
7
  * it does not reimplement any of its methods (`state`/`waitFor`/`events`/
8
8
  * `input.hold`/`input.tap`/`screenshot`/`command`/`page` — whatever
9
9
  * `GameClient` exposes is exposed here, unchanged).
10
+ *
11
+ * When the editor has MORE THAN ONE instance mounted (multiplayer authoring —
12
+ * see the engine's play-mode `mountAdditionalInstance`), `game` alone REFUSES
13
+ * to guess which one a call addresses. `game.instances()` / `game.instance(id)`
14
+ * are the way past that: each returns a `GameClient` whose relay carries a
15
+ * specific mount id (`RelayTransport({ instance })`), so one script can drive
16
+ * several instances — two seats of a match, or one scene under two seeds —
17
+ * without ambiguity.
10
18
  */
11
- import { GameClient, RelayTransport } from '@vgai/e2e';
19
+ import { GameClient, RelayTransport } from './game-client/index.js';
12
20
  /**
13
- * Builds a `GameClient` over a `RelayTransport({ port })`.
21
+ * Build a `GameClient` on `port`, optionally addressing one mounted instance.
14
22
  *
15
23
  * Fence values (`fenceTick`/`fenceSeq`/`fenceSimSeconds`/`fenceWallMs`) are
16
24
  * placeholders — tick 0, seq 0, sim-seconds 0, "now" — rather than a real
17
- * play-mode-start snapshot. `@vgai/e2e`'s own relay fixture
18
- * (`relay-fixture.ts`'s `waitForRelayBridge`) waits for a FRESH play-mode
19
- * boot before constructing its `game` client, because a test always starts
20
- * from a known instant. A `connect()`ed live session has no such instant
21
- * it may attach to an ALREADY-RUNNING game session so there is no single
22
- * "test start" to fence from. Practical effect: `game.events.expect(...)`
23
- * sees the WHOLE event history since play mode started (not a per-call
24
- * window); every other method (`state`/`waitFor`/`input`/`command`/
25
- * `screenshot`) is unaffected by the fence. `pageErrors`/`consoleErrors` are
26
- * always empty — relay mode has no separate page handle to listen on (the
27
- * same documented gap `relay-fixture.ts` carries).
25
+ * play-mode-start snapshot. A caller that owns its own browser can fence off
26
+ * a FRESH play-mode boot, because it starts from a known instant. A
27
+ * `connect()`ed live session has no such instant it may attach to an
28
+ * ALREADY-RUNNING game session so there is no single "run start" to fence
29
+ * from. Practical effect: `game.events.expect(...)` sees the WHOLE event
30
+ * history since play mode started (not a per-call window); every other method
31
+ * (`state`/`waitFor`/`input`/`command`/`screenshot`) is unaffected by the
32
+ * fence. `pageErrors`/`consoleErrors` are always empty — relay mode has no
33
+ * separate page handle to listen on.
28
34
  */
29
- export function createGameClient(port, artifactsDir) {
35
+ function gameClientFor(port, artifactsDir, instance) {
30
36
  return new GameClient({
31
- transport: new RelayTransport({ port }),
37
+ transport: new RelayTransport(instance === undefined ? { port } : { port, instance }),
32
38
  pageErrors: [],
33
39
  consoleErrors: [],
34
40
  fenceTick: 0,
@@ -39,3 +45,40 @@ export function createGameClient(port, artifactsDir) {
39
45
  artifactsDir,
40
46
  });
41
47
  }
48
+ /** The unaddressed `game` client — targets the sole live instance and refuses
49
+ * when several are mounted. Kept as a named export for callers/tests that
50
+ * want just the base client. */
51
+ export function createGameClient(port, artifactsDir) {
52
+ return gameClientFor(port, artifactsDir);
53
+ }
54
+ /** Query the editor's live instance ids over the session wire.
55
+ *
56
+ * `list-instances` is a session-level command (`command-listener.ts`), NOT a
57
+ * `bridge-call` method: it answers "which instances exist", and the
58
+ * bridge-call path RESOLVES an instance (refusing when several are live), so
59
+ * routing this through it would hit the exact ambiguity it throws on. The
60
+ * response spreads `data` at the top level, so the ids arrive as
61
+ * `body.instances`. */
62
+ async function listInstanceIds(port) {
63
+ const res = await fetch(`http://127.0.0.1:${port}/__editor/command`, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ type: 'list-instances' }),
67
+ });
68
+ const body = (await res.json());
69
+ if (!body.ok || !Array.isArray(body.instances)) {
70
+ throw new Error(`@vgai/live: list-instances failed — ${body.error ?? 'no instances in reply'}`);
71
+ }
72
+ return body.instances.map(String);
73
+ }
74
+ /** Build the `LiveGame` — the base `game` client plus its instance-addressing
75
+ * surface. */
76
+ export function createLiveGame(port, artifactsDir) {
77
+ const base = gameClientFor(port, artifactsDir);
78
+ // Tag each addressed handle with the mount id it drives, so a caller can pass
79
+ // `handle.id` to `tools.run(..., { instance })`. The id is already known here
80
+ // (it is what parameterizes the relay); attaching it just hands it back.
81
+ const instance = (id) => Object.assign(gameClientFor(port, artifactsDir, id), { id });
82
+ const instances = async () => (await listInstanceIds(port)).map(instance);
83
+ return Object.assign(base, { instance, instances });
84
+ }