@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,122 @@
1
+ /**
2
+ * Pure `game.fastForward` batching/accounting math — no Playwright (same
3
+ * "pure module, Playwright-free" split `wait-for.ts` established, so this
4
+ * unit-tests headlessly). `client.ts`'s `GameClient.fastForward` is the
5
+ * thin Playwright-bound wrapper around `runFastForward` below, exactly the
6
+ * way `GameClient.waitFor` wraps `runWaitFor`.
7
+ *
8
+ * The shape: `game.fastForward({simSeconds}|{simTicks})` synchronously
9
+ * drives the live `Game`'s `runTicks` (through the debug bridge, door (a))
10
+ * instead of relying on real wall-clock time to pass. Doctrine:
11
+ * fastForward is for SETUP/STAGING traversal —
12
+ * reaching a known late-game state fast — not a replacement for real-input
13
+ * proofs, which still run in real ticks; a scripted/scheduled input sequence
14
+ * driven THROUGH a fastForward burst is still an honest proof of "what the sim
15
+ * consumed" (the post-gate recording framing) — fastForward changes
16
+ * how fast ticks are produced, never what a tick consumes.
17
+ *
18
+ * Two honesty decisions this module encodes, both because burst ticks are NOT
19
+ * ordinary wall-clock-paced simulation and must never be reported as if they
20
+ * were (the "never fabricate" rule threaded through `perf-sampling.ts`/
21
+ * `failure-block.ts`):
22
+ *
23
+ * 1. **Batching, not one giant `runTicks` call.** A single call driving (say)
24
+ * an hour of sim time would (a) block the page's JS thread for the ENTIRE
25
+ * burst with zero observable progress, and (b) starve anything watching
26
+ * this run's stdout for liveness for however long the burst takes — a
27
+ * false "wedged" verdict on a run that was making fine (just silent)
28
+ * progress. Batching into `DEFAULT_FAST_FORWARD_BATCH_TICKS`-sized chunks
29
+ * with one heartbeat per chunk (`client.ts` prints it) keeps output flowing
30
+ * without giving up the speedup — each batch is still driven synchronously
31
+ * in-page; only the ROUND TRIP is chunked.
32
+ * 2. **Burst ticks never feed the ordinary tps stats.** `perf-sampling.ts`'s
33
+ * `TpsAccumulator` measures REAL wall-clock throughput between ordinary
34
+ * `snapshot()` polls — its whole purpose is "is the game's frame loop
35
+ * keeping up in real time." A burst of, say, 1800 ticks completed in
36
+ * ~50ms would produce a `~36000 ticks/s` sample that means nothing about
37
+ * the game's real per-frame cost, and would silently corrupt the p50/p95
38
+ * a genuine stall would otherwise surface in. `client.ts`'s `fastForward`
39
+ * therefore drives every batch through a RAW bridge call (bypassing
40
+ * `GameClient.snapshot()`'s automatic `TpsAccumulator.record`) and resets
41
+ * the accumulator's baseline once the burst completes, so the very next
42
+ * ordinary poll starts a fresh "first observation" instead of diffing
43
+ * across the burst discontinuity. The whole-TEST `simSpeedRatio` (fixture
44
+ * teardown, `perf-sampling.ts`) is NOT protected this way, deliberately:
45
+ * it is an honest end-to-end ratio (fence snapshot -> final snapshot),
46
+ * not a sampled rate, and showing "this test ran at 400x" when it
47
+ * genuinely did IS the correct, undistorted signal. `waitFor` budgets are
48
+ * themselves unaffected by any of this — they are still sim-time
49
+ * budgets; a `fastForward` immediately before one just leaves less real
50
+ * ground for it to cover (sim-time got CHEAPER, not redefined).
51
+ */
52
+ /** One fixed timestep, matching every real host's loop construction
53
+ * (`createGameLoop`'s own `fixedTimestep ?? 1/60` default: "fixedDt = the
54
+ * host loop's fixed timestep (1/60)"). Used ONLY as a fallback when a
55
+ * `simSeconds` budget needs converting to a tick count before the game has
56
+ * ticked even once (nothing observed yet to measure the real fixedDt from) —
57
+ * see `ticksForBudget`. */
58
+ export const DEFAULT_FIXED_DT = 1 / 60;
59
+ /** 5 sim-seconds' worth of ticks at the default 60Hz rate — small enough that
60
+ * even a complex game's batch finishes well inside a second of real time
61
+ * (keeping heartbeats frequent), large enough that round-trip overhead stays
62
+ * negligible next to the speedup (module doc, point 1). */
63
+ export const DEFAULT_FAST_FORWARD_BATCH_TICKS = 300;
64
+ /** Converts a `simSeconds` budget into an exact tick count using the game's
65
+ * OWN observed `simSeconds`/`tick` ratio — not a hardcoded constant, since a
66
+ * project may run a non-default fixed timestep (see `simulate-cinematic`'s
67
+ * `vgai-simulate-fixed-dt` precedent); measuring beats assuming. Falls back
68
+ * to `DEFAULT_FIXED_DT` only when `observed.tick` is still `0` (nothing to
69
+ * measure from yet — a fresh page that hasn't ticked once). A `simTicks`
70
+ * budget passes straight through, unaffected by any of this. */
71
+ export function ticksForBudget(budget, observed) {
72
+ if ('simTicks' in budget) {
73
+ if (!Number.isInteger(budget.simTicks) || budget.simTicks < 0) {
74
+ throw new RangeError(`game.fastForward: simTicks must be a non-negative integer, got ${budget.simTicks}`);
75
+ }
76
+ return budget.simTicks;
77
+ }
78
+ if (!Number.isFinite(budget.simSeconds) || budget.simSeconds < 0) {
79
+ throw new RangeError(`game.fastForward: simSeconds must be >= 0, got ${budget.simSeconds}`);
80
+ }
81
+ const fixedDt = observed.tick > 0 ? observed.simSeconds / observed.tick : DEFAULT_FIXED_DT;
82
+ return Math.ceil(budget.simSeconds / fixedDt);
83
+ }
84
+ /** Pure batch-size planner: splits `totalTicks` into chunks of at most
85
+ * `batchTicks`, the LAST of which may be smaller (never any other position —
86
+ * the driver marks exactly the last chunk "final" and applies the caller's
87
+ * requested render mode only to it). Empty for `totalTicks <= 0`. */
88
+ export function planFastForwardBatches(totalTicks, batchTicks) {
89
+ if (totalTicks <= 0)
90
+ return [];
91
+ const batches = [];
92
+ let remaining = totalTicks;
93
+ while (remaining > 0) {
94
+ const chunk = Math.min(batchTicks, remaining);
95
+ batches.push(chunk);
96
+ remaining -= chunk;
97
+ }
98
+ return batches;
99
+ }
100
+ /**
101
+ * Drives a `fastForward` call against `clock`: converts `budget` to an exact
102
+ * tick count (`ticksForBudget`), batches it (`planFastForwardBatches`), runs
103
+ * each batch through `runTicksBatch` (forcing `render: 'none'` on every batch
104
+ * but the last, which gets `opts.render ?? 'last'`), heartbeats once per
105
+ * batch, and returns the final observed `{tick, simSeconds}`. See the module
106
+ * doc for why batching and the render-mode split exist.
107
+ */
108
+ export async function runFastForward(budget, opts, clock) {
109
+ const observed = await clock.readTime();
110
+ const totalTicks = ticksForBudget(budget, observed);
111
+ const batches = planFastForwardBatches(totalTicks, opts.batchTicks ?? DEFAULT_FAST_FORWARD_BATCH_TICKS);
112
+ const finalRender = opts.render ?? 'last';
113
+ let ticksDone = 0;
114
+ for (let i = 0; i < batches.length; i++) {
115
+ const size = batches[i];
116
+ const isFinalBatch = i === batches.length - 1;
117
+ await clock.runTicksBatch(size, isFinalBatch ? finalRender : 'none');
118
+ ticksDone += size;
119
+ clock.heartbeat({ ticksDone, ticksTotal: totalTicks });
120
+ }
121
+ return clock.readTime();
122
+ }
@@ -0,0 +1,85 @@
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
+ export declare const HIDDEN_RECOVERY_STALL_POLLS = 10;
24
+ export interface HiddenRecoveryState {
25
+ /** Consecutive polls (so far) where the sim tick has not advanced. */
26
+ stalledPolls: number;
27
+ /** Latches true the moment `bringToFront` has fired once — never resets,
28
+ * even if the tick later moves and then stalls again, per "once". */
29
+ recovered: boolean;
30
+ }
31
+ export declare const initialHiddenRecoveryState: HiddenRecoveryState;
32
+ export interface PollObservation {
33
+ /** True when this poll's snapshot tick equals the previous poll's tick. */
34
+ tickUnchanged: boolean;
35
+ /** `document.hidden`, sampled by the caller — only meaningful (and only
36
+ * ever sampled by real callers) once `shouldSampleHidden` says so;
37
+ * `undefined` means "not sampled this poll". */
38
+ hidden?: boolean | undefined;
39
+ }
40
+ export type HiddenRecoveryAction = 'none' | 'bring-to-front';
41
+ /** Whether THIS poll's caller should bother sampling `document.hidden` at
42
+ * all — an efficiency guard so a healthy, moving game never pays an extra
43
+ * `page.evaluate()` per poll. True only once the stall threshold is about
44
+ * to be (or has been) reached, the tick is (still) unchanged, and recovery
45
+ * hasn't already fired. */
46
+ export declare function shouldSampleHidden(state: HiddenRecoveryState, tickUnchanged: boolean): boolean;
47
+ /** One pure transition. Called once per `runWaitFor` poll with the previous
48
+ * state and this poll's observation; returns the next state plus the
49
+ * action the caller should take. */
50
+ export declare function stepHiddenRecovery(state: HiddenRecoveryState, obs: PollObservation): {
51
+ state: HiddenRecoveryState;
52
+ action: HiddenRecoveryAction;
53
+ };
54
+ /** The page-facing side effects the driver needs, injected so this module
55
+ * never imports Playwright (`client.ts` supplies the real ones; tests
56
+ * supply scripted fakes). */
57
+ export interface HiddenRecoveryHooks {
58
+ /** Real implementation: `page.evaluate(() => document.hidden)`. */
59
+ sampleHidden(): Promise<boolean> | boolean;
60
+ /** Real implementation: `page.bringToFront()`. */
61
+ bringToFront(): Promise<void> | void;
62
+ /** Real implementation: `console.log(line)` + push onto the client's
63
+ * console-errors collection so the line also surfaces in an eventual
64
+ * failure block's console-errors section. */
65
+ log(line: string): void;
66
+ }
67
+ /** Renders the structured recovery line — one stable, greppable shape. */
68
+ export declare function hiddenRecoveryLogLine(tick: number): string;
69
+ /**
70
+ * Stateful wrapper over the pure transition above, fed one tick observation
71
+ * per poll (`GameClient.snapshot()` calls `observeTick` on every read). All
72
+ * side effects go through the injected hooks; a hook failure (page already
73
+ * closed, say) is swallowed — a recovery attempt must never be what fails a
74
+ * test.
75
+ */
76
+ export declare class HiddenRecoveryDriver {
77
+ private readonly hooks;
78
+ private state;
79
+ private lastTick;
80
+ private didTrigger;
81
+ constructor(hooks: HiddenRecoveryHooks);
82
+ /** True once `bringToFront()` has fired (at most once per driver/test). */
83
+ wasTriggered(): boolean;
84
+ observeTick(tick: number): Promise<void>;
85
+ }
@@ -0,0 +1,105 @@
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
+ export const HIDDEN_RECOVERY_STALL_POLLS = 10;
24
+ export const initialHiddenRecoveryState = {
25
+ stalledPolls: 0,
26
+ recovered: false,
27
+ };
28
+ /** Whether THIS poll's caller should bother sampling `document.hidden` at
29
+ * all — an efficiency guard so a healthy, moving game never pays an extra
30
+ * `page.evaluate()` per poll. True only once the stall threshold is about
31
+ * to be (or has been) reached, the tick is (still) unchanged, and recovery
32
+ * hasn't already fired. */
33
+ export function shouldSampleHidden(state, tickUnchanged) {
34
+ if (state.recovered || !tickUnchanged)
35
+ return false;
36
+ return state.stalledPolls + 1 >= HIDDEN_RECOVERY_STALL_POLLS;
37
+ }
38
+ /** One pure transition. Called once per `runWaitFor` poll with the previous
39
+ * state and this poll's observation; returns the next state plus the
40
+ * action the caller should take. */
41
+ export function stepHiddenRecovery(state, obs) {
42
+ if (!obs.tickUnchanged) {
43
+ return { state: { stalledPolls: 0, recovered: state.recovered }, action: 'none' };
44
+ }
45
+ const stalledPolls = state.stalledPolls + 1;
46
+ if (state.recovered || obs.hidden !== true) {
47
+ return { state: { stalledPolls, recovered: state.recovered }, action: 'none' };
48
+ }
49
+ if (stalledPolls >= HIDDEN_RECOVERY_STALL_POLLS) {
50
+ return { state: { stalledPolls, recovered: true }, action: 'bring-to-front' };
51
+ }
52
+ return { state: { stalledPolls, recovered: false }, action: 'none' };
53
+ }
54
+ /** Renders the structured recovery line — one stable, greppable shape. */
55
+ export function hiddenRecoveryLogLine(tick) {
56
+ return (`vgai: hidden-tab recovery — sim tick frozen at ${tick} for ` +
57
+ `${HIDDEN_RECOVERY_STALL_POLLS} consecutive polls while document.hidden=true; ` +
58
+ 'calling page.bringToFront() once');
59
+ }
60
+ /**
61
+ * Stateful wrapper over the pure transition above, fed one tick observation
62
+ * per poll (`GameClient.snapshot()` calls `observeTick` on every read). All
63
+ * side effects go through the injected hooks; a hook failure (page already
64
+ * closed, say) is swallowed — a recovery attempt must never be what fails a
65
+ * test.
66
+ */
67
+ export class HiddenRecoveryDriver {
68
+ hooks;
69
+ state = initialHiddenRecoveryState;
70
+ lastTick = null;
71
+ didTrigger = false;
72
+ constructor(hooks) {
73
+ this.hooks = hooks;
74
+ }
75
+ /** True once `bringToFront()` has fired (at most once per driver/test). */
76
+ wasTriggered() {
77
+ return this.didTrigger;
78
+ }
79
+ async observeTick(tick) {
80
+ const tickUnchanged = this.lastTick !== null && tick === this.lastTick;
81
+ this.lastTick = tick;
82
+ let hidden;
83
+ if (shouldSampleHidden(this.state, tickUnchanged)) {
84
+ try {
85
+ hidden = await this.hooks.sampleHidden();
86
+ }
87
+ catch {
88
+ hidden = undefined;
89
+ }
90
+ }
91
+ const { state, action } = stepHiddenRecovery(this.state, { tickUnchanged, hidden });
92
+ this.state = state;
93
+ if (action === 'bring-to-front') {
94
+ this.didTrigger = true;
95
+ this.hooks.log(hiddenRecoveryLogLine(tick));
96
+ try {
97
+ await this.hooks.bringToFront();
98
+ }
99
+ catch {
100
+ // Same rule as sampleHidden: never let the recovery attempt itself
101
+ // throw into a spec — the sim-time budget remains the only failure.
102
+ }
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,40 @@
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
+ export type { BridgeHeartbeatState } from './bridge-heartbeat.js';
17
+ export { BRIDGE_HEARTBEAT_INTERVAL_MS, formatBridgeHeartbeatLine } from './bridge-heartbeat.js';
18
+ export type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
19
+ export type { GameClientOptions } from './client.js';
20
+ export { GameClient, GameEvents, GameInput, PageTransport } from './client.js';
21
+ export type { SessionErrorCode } from './errors.js';
22
+ export { SESSION_ERROR_CODES, SessionError, SessionFailure } from './errors.js';
23
+ export type { EventsMatchOptions, EventsMatchResult } from './events-matcher.js';
24
+ export { matchEventsSubsequence } from './events-matcher.js';
25
+ export type { AssembledFailureBlock, FailureBlockContext, SessionFailureData, } from './failure-block.js';
26
+ export type { FastForwardBudget, FastForwardClock, FastForwardOptions, FastForwardRenderMode, FastForwardTime, } from './fast-forward.js';
27
+ export { DEFAULT_FAST_FORWARD_BATCH_TICKS, DEFAULT_FIXED_DT, planFastForwardBatches, runFastForward, ticksForBudget, } from './fast-forward.js';
28
+ export type { HiddenRecoveryAction, HiddenRecoveryHooks, HiddenRecoveryState, PollObservation, } from './hidden-recovery.js';
29
+ export { HIDDEN_RECOVERY_STALL_POLLS, HiddenRecoveryDriver, hiddenRecoveryLogLine, initialHiddenRecoveryState, shouldSampleHidden, stepHiddenRecovery, } from './hidden-recovery.js';
30
+ export type { TpsStats } from './perf-sampling.js';
31
+ export { computeTicksPerSecond, percentile, simSpeedRatio, summarizeTpsSamples, TpsAccumulator, } from './perf-sampling.js';
32
+ export type { RelayTransportOptions } from './relay-transport.js';
33
+ export { RelayTransport } from './relay-transport.js';
34
+ export type { ScreenshotArgKind, ScreenshotTarget, ScreenshotTargetInput, } from './screenshot-target.js';
35
+ export { classifyScreenshotArg, resolveScreenshotTarget, sanitizeScreenshotLabel, } from './screenshot-target.js';
36
+ export type { CappedJson } from './state-cap.js';
37
+ export { capJson } from './state-cap.js';
38
+ export type { DebugBridgeInput, DebugCommandInfo, DebugSnapshot, ProviderInfo, RunTicksOptions, TickStampedEvent, ValueTier, VgaiBridgeHandle, VirtualActionResult, VirtualActionValue, } from './types.js';
39
+ export type { WaitForBudget } from './wait-for.js';
40
+ export { assertValidWaitForBudget, WAIT_FOR_STALL_POLL_LIMIT, WAIT_FOR_TIMEOUT_OPTION_MESSAGE, } from './wait-for.js';
@@ -0,0 +1,26 @@
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
+ export { BRIDGE_HEARTBEAT_INTERVAL_MS, formatBridgeHeartbeatLine } from './bridge-heartbeat.js';
17
+ export { GameClient, GameEvents, GameInput, PageTransport } from './client.js';
18
+ export { SESSION_ERROR_CODES, SessionError, SessionFailure } from './errors.js';
19
+ export { matchEventsSubsequence } from './events-matcher.js';
20
+ export { DEFAULT_FAST_FORWARD_BATCH_TICKS, DEFAULT_FIXED_DT, planFastForwardBatches, runFastForward, ticksForBudget, } from './fast-forward.js';
21
+ export { HIDDEN_RECOVERY_STALL_POLLS, HiddenRecoveryDriver, hiddenRecoveryLogLine, initialHiddenRecoveryState, shouldSampleHidden, stepHiddenRecovery, } from './hidden-recovery.js';
22
+ export { computeTicksPerSecond, percentile, simSpeedRatio, summarizeTpsSamples, TpsAccumulator, } from './perf-sampling.js';
23
+ export { RelayTransport } from './relay-transport.js';
24
+ export { classifyScreenshotArg, resolveScreenshotTarget, sanitizeScreenshotLabel, } from './screenshot-target.js';
25
+ export { capJson } from './state-cap.js';
26
+ export { assertValidWaitForBudget, WAIT_FOR_STALL_POLL_LIMIT, WAIT_FOR_TIMEOUT_OPTION_MESSAGE, } from './wait-for.js';
@@ -0,0 +1,56 @@
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
+ /** One poll's instantaneous ticks-per-second, or `null` when `wallDeltaMs`
14
+ * is non-positive (first poll / a clock with sub-millisecond resolution) —
15
+ * callers should simply skip a `null` sample rather than recording it. */
16
+ export declare function computeTicksPerSecond(tickDelta: number, wallDeltaMs: number): number | null;
17
+ /** Nearest-rank percentile (1-indexed rank `ceil(p/100 * n)`, clamped into
18
+ * range) over an ALREADY-SORTED-ASCENDING array. `0` on an empty input. */
19
+ export declare function percentile(sortedAscending: readonly number[], p: number): number;
20
+ export interface TpsStats {
21
+ p50: number;
22
+ p95: number;
23
+ sampleCount: number;
24
+ }
25
+ /** Summarizes raw ticks-per-second samples (any order) into p50/p95 +
26
+ * count. `{ p50: 0, p95: 0, sampleCount: 0 }` when there are no samples
27
+ * (a test that never called `game.waitFor` — honestly reported as zero
28
+ * samples, never fabricated). */
29
+ export declare function summarizeTpsSamples(samples: readonly number[]): TpsStats;
30
+ /** Whole-test sim-speed ratio: sim-seconds elapsed over wall-seconds
31
+ * elapsed. `0` when `wallMs` is non-positive (mirrors `failure-block.ts`'s
32
+ * own `ratioLine` degrade-to-zero rule for a zero/negative denominator). */
33
+ export declare function simSpeedRatio(simSecondsDelta: number, wallMs: number): number;
34
+ /**
35
+ * Accumulates one tick-rate sample per snapshot read. `GameClient.snapshot()`
36
+ * calls `record(tick, wallNowMs)` on every read it was already doing (waitFor
37
+ * polls, waitSimTime polls, explicit snapshots) — no extra `page.evaluate()`
38
+ * round trips. The first observation only seeds the baseline; a NEGATIVE
39
+ * tick delta (a page reload reset the sim clock) reseeds rather than
40
+ * recording a nonsense sample; a zero tick delta IS recorded (0 ticks/s is
41
+ * the honest "loop frozen" signal the summary exists to surface).
42
+ */
43
+ export declare class TpsAccumulator {
44
+ private last;
45
+ private readonly samples;
46
+ record(tick: number, wallMs: number): void;
47
+ /** D15: forgets the baseline (not the accumulated samples) — the NEXT
48
+ * `record()` call becomes a fresh "first observation" (seeds only, no
49
+ * sample), exactly like the very first `record()` this accumulator ever
50
+ * sees. `client.ts`'s `fastForward` calls this right after a burst of
51
+ * `runTicks`, so the burst's enormous tick delta over a near-zero wall
52
+ * delta is never diffed into a nonsense tps sample (`fast-forward.ts`'s
53
+ * module doc, point 2) — real per-poll sampling simply resumes from here. */
54
+ resetBaseline(): void;
55
+ stats(): TpsStats;
56
+ }
@@ -0,0 +1,85 @@
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
+ /** One poll's instantaneous ticks-per-second, or `null` when `wallDeltaMs`
14
+ * is non-positive (first poll / a clock with sub-millisecond resolution) —
15
+ * callers should simply skip a `null` sample rather than recording it. */
16
+ export function computeTicksPerSecond(tickDelta, wallDeltaMs) {
17
+ if (wallDeltaMs <= 0)
18
+ return null;
19
+ return (tickDelta / wallDeltaMs) * 1000;
20
+ }
21
+ /** Nearest-rank percentile (1-indexed rank `ceil(p/100 * n)`, clamped into
22
+ * range) over an ALREADY-SORTED-ASCENDING array. `0` on an empty input. */
23
+ export function percentile(sortedAscending, p) {
24
+ const n = sortedAscending.length;
25
+ if (n === 0)
26
+ return 0;
27
+ const rank = Math.min(n, Math.max(1, Math.ceil((p / 100) * n)));
28
+ return sortedAscending[rank - 1];
29
+ }
30
+ /** Summarizes raw ticks-per-second samples (any order) into p50/p95 +
31
+ * count. `{ p50: 0, p95: 0, sampleCount: 0 }` when there are no samples
32
+ * (a test that never called `game.waitFor` — honestly reported as zero
33
+ * samples, never fabricated). */
34
+ export function summarizeTpsSamples(samples) {
35
+ const sorted = [...samples].sort((a, b) => a - b);
36
+ return {
37
+ p50: percentile(sorted, 50),
38
+ p95: percentile(sorted, 95),
39
+ sampleCount: sorted.length,
40
+ };
41
+ }
42
+ /** Whole-test sim-speed ratio: sim-seconds elapsed over wall-seconds
43
+ * elapsed. `0` when `wallMs` is non-positive (mirrors `failure-block.ts`'s
44
+ * own `ratioLine` degrade-to-zero rule for a zero/negative denominator). */
45
+ export function simSpeedRatio(simSecondsDelta, wallMs) {
46
+ return wallMs > 0 ? simSecondsDelta / (wallMs / 1000) : 0;
47
+ }
48
+ /**
49
+ * Accumulates one tick-rate sample per snapshot read. `GameClient.snapshot()`
50
+ * calls `record(tick, wallNowMs)` on every read it was already doing (waitFor
51
+ * polls, waitSimTime polls, explicit snapshots) — no extra `page.evaluate()`
52
+ * round trips. The first observation only seeds the baseline; a NEGATIVE
53
+ * tick delta (a page reload reset the sim clock) reseeds rather than
54
+ * recording a nonsense sample; a zero tick delta IS recorded (0 ticks/s is
55
+ * the honest "loop frozen" signal the summary exists to surface).
56
+ */
57
+ export class TpsAccumulator {
58
+ last = null;
59
+ samples = [];
60
+ record(tick, wallMs) {
61
+ const last = this.last;
62
+ this.last = { tick, wallMs };
63
+ if (last === null)
64
+ return;
65
+ const tickDelta = tick - last.tick;
66
+ if (tickDelta < 0)
67
+ return;
68
+ const tps = computeTicksPerSecond(tickDelta, wallMs - last.wallMs);
69
+ if (tps !== null)
70
+ this.samples.push(tps);
71
+ }
72
+ /** D15: forgets the baseline (not the accumulated samples) — the NEXT
73
+ * `record()` call becomes a fresh "first observation" (seeds only, no
74
+ * sample), exactly like the very first `record()` this accumulator ever
75
+ * sees. `client.ts`'s `fastForward` calls this right after a burst of
76
+ * `runTicks`, so the burst's enormous tick delta over a near-zero wall
77
+ * delta is never diffed into a nonsense tps sample (`fast-forward.ts`'s
78
+ * module doc, point 2) — real per-poll sampling simply resumes from here. */
79
+ resetBaseline() {
80
+ this.last = null;
81
+ }
82
+ stats() {
83
+ return summarizeTpsSamples(this.samples);
84
+ }
85
+ }
@@ -0,0 +1,100 @@
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
+ import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
22
+ export interface RelayTransportOptions {
23
+ /** The live editor session's dev-server port (e.g. from
24
+ * `findLiveEditorSession`/`vgai edit`). */
25
+ port: number;
26
+ /** Per-call fetch timeout, ms — default covers an ordinary sync-style
27
+ * bridge call; `callAsync` (debug `invoke`, which may run arbitrary game
28
+ * code) gets its own longer budget regardless of this override, see
29
+ * `INVOKE_TIMEOUT_MS`. */
30
+ timeoutMs?: number;
31
+ /**
32
+ * WHICH mounted game these calls address, when the editor has more than one.
33
+ *
34
+ * Omitted is the single-player case and stays the default: the browser
35
+ * resolves the sole live instance. With several mounted it REFUSES rather
36
+ * than guessing (`systemsForInstance`), because the dangerous failure there
37
+ * is not an error — it is success on the wrong game, with every log line
38
+ * reading fine.
39
+ *
40
+ * Rides on `bridge-call` only. A screenshot captures the page, not an
41
+ * instance, and `page-script` reaches the page itself.
42
+ */
43
+ instance?: string;
44
+ }
45
+ export declare class RelayTransport implements BridgeTransport {
46
+ private readonly baseUrl;
47
+ private readonly timeoutMs;
48
+ private hiddenDriveLastWallMs;
49
+ private hiddenDriveAnnounced;
50
+ private forceHiddenDrive;
51
+ private hiddenCache;
52
+ private readonly instance;
53
+ constructor(opts: RelayTransportOptions);
54
+ private postCommand;
55
+ /** Maps the relay's wire body back onto the transport-neutral
56
+ * `BridgeCallOutcome` — `result` on success, `code`/`error`/the rest of
57
+ * `data` on failure. This is the exact property the round-trip unit test
58
+ * proves: `code`/`data` survive byte-equivalent to `PageTransport`'s own
59
+ * `unwrap()` path. */
60
+ private toBridgeOutcome;
61
+ private bridgeCall;
62
+ /**
63
+ * The PREFLIGHT every leg shares: sample the tab's visibility (through the
64
+ * short TTL cache above) and, the first time it reads hidden, say so ONCE on
65
+ * stdout in a stable, greppable line naming the cause and the fix.
66
+ *
67
+ * It runs on every leg, not just `call('snapshot')`, because that is where
68
+ * the measured gap was: a bot that drives the game with `hold`/`command` and
69
+ * reads through them — the shape `npm run playtest` actually has — could run
70
+ * its entire session against a backgrounded tab and never be told, so a
71
+ * later failure read as a generic relay timeout instead of "your tab is
72
+ * hidden". Returns whether the tab is hidden so `call` can decide whether to
73
+ * also drive ticks.
74
+ */
75
+ private preflightHidden;
76
+ /** `isHidden()` is an HTTP round trip; the preflight now runs on every leg,
77
+ * so a short TTL keeps that from multiplying a bot's request count while
78
+ * still reacting to a tab the human foregrounds mid-run. */
79
+ private isHiddenCached;
80
+ call(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome>;
81
+ callAsync(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome>;
82
+ isHidden(): Promise<boolean>;
83
+ /**
84
+ * A relay cannot foreground a person's browser tab. If the generic hidden
85
+ * recovery driver reaches this hook, force the same deterministic stepping
86
+ * path `call('snapshot')` normally activates from `/__editor/state`.
87
+ */
88
+ bringToFront(): Promise<void>;
89
+ screenshot(path: string): Promise<void>;
90
+ /**
91
+ * Wave-2: ships `src` (`step.toString()`) to the editor dev server's
92
+ * `page-script` op — a STANDALONE relay command (like `bridge-screenshot`
93
+ * above), not a `bridge-call` method (see `command-listener.ts`'s
94
+ * `handlePageScript` doc comment for why). `step` itself is unused on this
95
+ * leg — closures don't survive the wire, see `bridge-transport.ts`'s
96
+ * `runPageScript` doc comment — kept only to satisfy the shared interface
97
+ * `PageTransport` (which DOES call it directly) also implements.
98
+ */
99
+ runPageScript(src: string, _step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
100
+ }