@vgai/live 0.5.23 → 0.5.25

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.
@@ -76,6 +76,15 @@ export interface BridgeTransport {
76
76
  * serialized — inline every value the step needs.
77
77
  */
78
78
  runPageScript(src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
79
+ /**
80
+ * THE MODULE LANE — run a step INSIDE the editor page against
81
+ * `{ page, modules, instanceId }`, where `modules(path)` imports the
82
+ * RUNNING mount's own instance of a project module (never a phantom
83
+ * second copy). Same serialization contract as `runPageScript`: the
84
+ * step's source travels as text, closures do not survive, and the return
85
+ * value must be plain data. `instance` scopes multi-instance sessions.
86
+ */
87
+ runGameScript(src: string, step: (scope: unknown) => unknown, instance?: string): Promise<BridgeCallOutcome>;
79
88
  /**
80
89
  * P20 — reload the document showing the game, resolving only once the page
81
90
  * is BACK and taking commands again.
@@ -39,6 +39,13 @@ export declare class PageTransport implements BridgeTransport {
39
39
  * full honesty-boundary contract; `src` is unused on this leg, kept only
40
40
  * to satisfy the shared interface). */
41
41
  runPageScript(_src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
42
+ /** THE MODULE LANE under a real Playwright host: the step's source is
43
+ * evaluated INSIDE the editor page via the same in-page handler the relay
44
+ * op uses (`window.__vgaiGameEval`), because `modules()` only means
45
+ * anything in the page's own module space — a Node-side call could never
46
+ * hand back the running mount's instances. Same serialization contract
47
+ * as the relay leg. */
48
+ runGameScript(src: string, _step: (scope: unknown) => unknown, instance?: string): Promise<BridgeCallOutcome>;
42
49
  /** Playwright's own reload already waits for the new document's `load`
43
50
  * event, which is exactly the completion signal this method's contract
44
51
  * asks for — nothing to reconstruct on this leg. */
@@ -296,6 +303,31 @@ export declare class GameClient {
296
303
  * `runPageScript` doc comment for the full contract this method wraps.
297
304
  */
298
305
  page<T = unknown>(step: (page: Page) => T | Promise<T>): Promise<T>;
306
+ /**
307
+ * THE MODULE LANE — run literal JS INSIDE the game's page, with the
308
+ * running mount's modules in reach:
309
+ *
310
+ * ```js
311
+ * await game.run(async ({ modules }) => {
312
+ * const { simHost } = await modules('src/sim/host.ts');
313
+ * return simHost().state.day;
314
+ * })
315
+ * ```
316
+ *
317
+ * `scope` is `{ page, modules, instanceId }`. Serialization contract as
318
+ * `game.page()`: the step travels as source (no closures), and the return
319
+ * value must be plain data. `modules(path)` resolves through the ACTIVE
320
+ * mount's own url space, so what you touch IS the running game — never a
321
+ * phantom second copy. Dev-server sessions only; a shipped build's curated
322
+ * surface is its adapter exports.
323
+ */
324
+ run<T = unknown>(step: (scope: {
325
+ page: Page;
326
+ modules: (path: string) => Promise<Record<string, unknown>>;
327
+ instanceId: string;
328
+ }) => T | Promise<T>, opts?: {
329
+ instance?: string;
330
+ }): Promise<T>;
299
331
  /**
300
332
  * Reload the document showing the game, resolving only once it is back and
301
333
  * answering commands (see `bridge-transport.ts`'s `reloadPage`).
@@ -128,6 +128,30 @@ export class PageTransport {
128
128
  };
129
129
  }
130
130
  }
131
+ /** THE MODULE LANE under a real Playwright host: the step's source is
132
+ * evaluated INSIDE the editor page via the same in-page handler the relay
133
+ * op uses (`window.__vgaiGameEval`), because `modules()` only means
134
+ * anything in the page's own module space — a Node-side call could never
135
+ * hand back the running mount's instances. Same serialization contract
136
+ * as the relay leg. */
137
+ async runGameScript(src, _step, instance) {
138
+ try {
139
+ const result = await this.page.evaluate(async (args) => {
140
+ const hook = window['__vgaiGameEval'];
141
+ if (typeof hook !== 'function') {
142
+ throw new Error('game-eval: this page has no __vgaiGameEval hook — is the editor page loaded?');
143
+ }
144
+ return hook(args.src, args.instance);
145
+ }, { src, ...(instance === undefined ? {} : { instance }) });
146
+ return { ok: true, result };
147
+ }
148
+ catch (err) {
149
+ return {
150
+ ok: false,
151
+ error: { code: undefined, message: err instanceof Error ? err.message : String(err) },
152
+ };
153
+ }
154
+ }
131
155
  /** Playwright's own reload already waits for the new document's `load`
132
156
  * event, which is exactly the completion signal this method's contract
133
157
  * asks for — nothing to reconstruct on this leg. */
@@ -422,6 +446,9 @@ export class GameClient {
422
446
  /** Per-test tick-rate samples, fed by every `snapshot()` read (a poll the
423
447
  * client was making anyway — zero extra page.evaluate round trips). */
424
448
  #tps = new TpsAccumulator();
449
+ /** True after the settled run-ticks door proved absent on this page (an older exported
450
+ * game's engine) — see `fastForward`'s `runTicksBatch`. */
451
+ #legacyRunTicksDoor = false;
425
452
  /** Hidden-tab recovery (hollowstone field lesson: the engine hard-stops
426
453
  * while `document.hidden`). Client-lifetime state so `bringToFront()`
427
454
  * fires at most once per test, across ALL waitFor/waitSimTime loops. */
@@ -504,7 +531,30 @@ export class GameClient {
504
531
  // names neither the method nor the shape).
505
532
  assertValidWaitForBudget(budget, 'fastForward');
506
533
  const clock = {
507
- runTicksBatch: (n, render) => this.callBridgeVoid('runTicks', n, { render }),
534
+ // The SETTLED door (`runTicksSettled`, an async bridge method): ticks never race a scene
535
+ // remount's async commit, so which tick first runs a freshly reloaded world is
536
+ // deterministic (see engine/runtime/run-ticks-settled.ts — measured: without it, one
537
+ // drive script produced 7 or 8 post-respawn walked ticks depending on wall timing).
538
+ // Falls back ONCE to the plain sync door for a page whose engine predates the method
539
+ // (an older exported game), and remembers the verdict for the rest of the burst.
540
+ runTicksBatch: async (n, render) => {
541
+ if (this.#legacyRunTicksDoor) {
542
+ await this.callBridgeVoid('runTicks', n, { render });
543
+ return;
544
+ }
545
+ try {
546
+ await this.callBridgeAsync('runTicksSettled', n, { render });
547
+ }
548
+ catch (error) {
549
+ const code = error.code;
550
+ const message = error instanceof Error ? error.message : String(error);
551
+ const doorAbsent = code === 'UNKNOWN_BRIDGE_METHOD' || /is not a function|undefined/i.test(message);
552
+ if (!doorAbsent)
553
+ throw error;
554
+ this.#legacyRunTicksDoor = true;
555
+ await this.callBridgeVoid('runTicks', n, { render });
556
+ }
557
+ },
508
558
  readTime: async () => {
509
559
  // Raw bridge read — deliberately NOT `this.snapshot()`, which would
510
560
  // feed the TpsAccumulator (see fast-forward.ts's module doc, point 2).
@@ -676,6 +726,29 @@ export class GameClient {
676
726
  const outcome = await this.#transport.runPageScript(step.toString(), erased);
677
727
  return this.unwrap(outcome);
678
728
  }
729
+ /**
730
+ * THE MODULE LANE — run literal JS INSIDE the game's page, with the
731
+ * running mount's modules in reach:
732
+ *
733
+ * ```js
734
+ * await game.run(async ({ modules }) => {
735
+ * const { simHost } = await modules('src/sim/host.ts');
736
+ * return simHost().state.day;
737
+ * })
738
+ * ```
739
+ *
740
+ * `scope` is `{ page, modules, instanceId }`. Serialization contract as
741
+ * `game.page()`: the step travels as source (no closures), and the return
742
+ * value must be plain data. `modules(path)` resolves through the ACTIVE
743
+ * mount's own url space, so what you touch IS the running game — never a
744
+ * phantom second copy. Dev-server sessions only; a shipped build's curated
745
+ * surface is its adapter exports.
746
+ */
747
+ async run(step, opts) {
748
+ const erased = (arg) => step(arg);
749
+ const outcome = await this.#transport.runGameScript(step.toString(), erased, opts?.instance);
750
+ return this.unwrap(outcome);
751
+ }
679
752
  /**
680
753
  * Reload the document showing the game, resolving only once it is back and
681
754
  * answering commands (see `bridge-transport.ts`'s `reloadPage`).
@@ -109,6 +109,8 @@ export declare class RelayTransport implements BridgeTransport {
109
109
  * `PageTransport` (which DOES call it directly) also implements.
110
110
  */
111
111
  runPageScript(src: string, _step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
112
+ /** THE MODULE LANE over the relay — same wire shape as `page-script`. */
113
+ runGameScript(src: string, _step: (scope: unknown) => unknown, instance?: string): Promise<BridgeCallOutcome>;
112
114
  /**
113
115
  * P20 — order the tab to reload, then wait for EVIDENCE that it came back.
114
116
  *
@@ -270,6 +270,23 @@ export class RelayTransport {
270
270
  };
271
271
  }
272
272
  }
273
+ /** THE MODULE LANE over the relay — same wire shape as `page-script`. */
274
+ async runGameScript(src, _step, instance) {
275
+ try {
276
+ const body = await this.postCommand({ type: 'game-eval', src, ...(instance === undefined ? {} : { instance }) }, PAGE_SCRIPT_TIMEOUT_MS);
277
+ return this.toBridgeOutcome(body);
278
+ }
279
+ catch (err) {
280
+ return {
281
+ ok: false,
282
+ error: {
283
+ code: 'RELAY_UNREACHABLE',
284
+ message: `vgai: could not reach the editor dev server relay at ${this.baseUrl} — ` +
285
+ `${err instanceof Error ? err.message : String(err)}`,
286
+ },
287
+ };
288
+ }
289
+ }
273
290
  /**
274
291
  * P20 — order the tab to reload, then wait for EVIDENCE that it came back.
275
292
  *
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/live",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.23",
5
+ "version": "0.5.25",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "prepack": "npm run build"
33
33
  },
34
34
  "dependencies": {
35
- "@vgai/editor-sdk": "0.5.23",
36
- "@vgai/sdk": "0.5.23"
35
+ "@vgai/editor-sdk": "0.5.25",
36
+ "@vgai/sdk": "0.5.25"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@playwright/test": ">=1.58.2 <2"
@@ -75,6 +75,19 @@ export interface BridgeTransport {
75
75
  * serialized — inline every value the step needs.
76
76
  */
77
77
  runPageScript(src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
78
+ /**
79
+ * THE MODULE LANE — run a step INSIDE the editor page against
80
+ * `{ page, modules, instanceId }`, where `modules(path)` imports the
81
+ * RUNNING mount's own instance of a project module (never a phantom
82
+ * second copy). Same serialization contract as `runPageScript`: the
83
+ * step's source travels as text, closures do not survive, and the return
84
+ * value must be plain data. `instance` scopes multi-instance sessions.
85
+ */
86
+ runGameScript(
87
+ src: string,
88
+ step: (scope: unknown) => unknown,
89
+ instance?: string,
90
+ ): Promise<BridgeCallOutcome>;
78
91
  /**
79
92
  * P20 — reload the document showing the game, resolving only once the page
80
93
  * is BACK and taking commands again.
@@ -158,6 +158,41 @@ export class PageTransport implements BridgeTransport {
158
158
  }
159
159
  }
160
160
 
161
+ /** THE MODULE LANE under a real Playwright host: the step's source is
162
+ * evaluated INSIDE the editor page via the same in-page handler the relay
163
+ * op uses (`window.__vgaiGameEval`), because `modules()` only means
164
+ * anything in the page's own module space — a Node-side call could never
165
+ * hand back the running mount's instances. Same serialization contract
166
+ * as the relay leg. */
167
+ async runGameScript(
168
+ src: string,
169
+ _step: (scope: unknown) => unknown,
170
+ instance?: string,
171
+ ): Promise<BridgeCallOutcome> {
172
+ try {
173
+ const result = await this.page.evaluate(
174
+ async (args: { src: string; instance?: string }) => {
175
+ const hook = (window as unknown as Record<string, unknown>)['__vgaiGameEval'] as
176
+ | ((src: string, instance?: string) => Promise<unknown>)
177
+ | undefined;
178
+ if (typeof hook !== 'function') {
179
+ throw new Error(
180
+ 'game-eval: this page has no __vgaiGameEval hook — is the editor page loaded?',
181
+ );
182
+ }
183
+ return hook(args.src, args.instance);
184
+ },
185
+ { src, ...(instance === undefined ? {} : { instance }) },
186
+ );
187
+ return { ok: true, result };
188
+ } catch (err) {
189
+ return {
190
+ ok: false,
191
+ error: { code: undefined, message: err instanceof Error ? err.message : String(err) },
192
+ };
193
+ }
194
+ }
195
+
161
196
  /** Playwright's own reload already waits for the new document's `load`
162
197
  * event, which is exactly the completion signal this method's contract
163
198
  * asks for — nothing to reconstruct on this leg. */
@@ -528,6 +563,9 @@ export class GameClient {
528
563
  /** Per-test tick-rate samples, fed by every `snapshot()` read (a poll the
529
564
  * client was making anyway — zero extra page.evaluate round trips). */
530
565
  readonly #tps = new TpsAccumulator();
566
+ /** True after the settled run-ticks door proved absent on this page (an older exported
567
+ * game's engine) — see `fastForward`'s `runTicksBatch`. */
568
+ #legacyRunTicksDoor = false;
531
569
  /** Hidden-tab recovery (hollowstone field lesson: the engine hard-stops
532
570
  * while `document.hidden`). Client-lifetime state so `bringToFront()`
533
571
  * fires at most once per test, across ALL waitFor/waitSimTime loops. */
@@ -619,7 +657,29 @@ export class GameClient {
619
657
  // names neither the method nor the shape).
620
658
  assertValidWaitForBudget(budget, 'fastForward');
621
659
  const clock: FastForwardClock = {
622
- runTicksBatch: (n, render) => this.callBridgeVoid('runTicks', n, { render }),
660
+ // The SETTLED door (`runTicksSettled`, an async bridge method): ticks never race a scene
661
+ // remount's async commit, so which tick first runs a freshly reloaded world is
662
+ // deterministic (see engine/runtime/run-ticks-settled.ts — measured: without it, one
663
+ // drive script produced 7 or 8 post-respawn walked ticks depending on wall timing).
664
+ // Falls back ONCE to the plain sync door for a page whose engine predates the method
665
+ // (an older exported game), and remembers the verdict for the rest of the burst.
666
+ runTicksBatch: async (n, render) => {
667
+ if (this.#legacyRunTicksDoor) {
668
+ await this.callBridgeVoid('runTicks', n, { render });
669
+ return;
670
+ }
671
+ try {
672
+ await this.callBridgeAsync<void>('runTicksSettled', n, { render });
673
+ } catch (error) {
674
+ const code = (error as { code?: string }).code;
675
+ const message = error instanceof Error ? error.message : String(error);
676
+ const doorAbsent =
677
+ code === 'UNKNOWN_BRIDGE_METHOD' || /is not a function|undefined/i.test(message);
678
+ if (!doorAbsent) throw error;
679
+ this.#legacyRunTicksDoor = true;
680
+ await this.callBridgeVoid('runTicks', n, { render });
681
+ }
682
+ },
623
683
  readTime: async () => {
624
684
  // Raw bridge read — deliberately NOT `this.snapshot()`, which would
625
685
  // feed the TpsAccumulator (see fast-forward.ts's module doc, point 2).
@@ -802,6 +862,37 @@ export class GameClient {
802
862
  return this.unwrap<T>(outcome);
803
863
  }
804
864
 
865
+ /**
866
+ * THE MODULE LANE — run literal JS INSIDE the game's page, with the
867
+ * running mount's modules in reach:
868
+ *
869
+ * ```js
870
+ * await game.run(async ({ modules }) => {
871
+ * const { simHost } = await modules('src/sim/host.ts');
872
+ * return simHost().state.day;
873
+ * })
874
+ * ```
875
+ *
876
+ * `scope` is `{ page, modules, instanceId }`. Serialization contract as
877
+ * `game.page()`: the step travels as source (no closures), and the return
878
+ * value must be plain data. `modules(path)` resolves through the ACTIVE
879
+ * mount's own url space, so what you touch IS the running game — never a
880
+ * phantom second copy. Dev-server sessions only; a shipped build's curated
881
+ * surface is its adapter exports.
882
+ */
883
+ async run<T = unknown>(
884
+ step: (scope: {
885
+ page: Page;
886
+ modules: (path: string) => Promise<Record<string, unknown>>;
887
+ instanceId: string;
888
+ }) => T | Promise<T>,
889
+ opts?: { instance?: string },
890
+ ): Promise<T> {
891
+ const erased = (arg: unknown) => step(arg as Parameters<typeof step>[0]);
892
+ const outcome = await this.#transport.runGameScript(step.toString(), erased, opts?.instance);
893
+ return this.unwrap<T>(outcome);
894
+ }
895
+
805
896
  /**
806
897
  * Reload the document showing the game, resolving only once it is back and
807
898
  * answering commands (see `bridge-transport.ts`'s `reloadPage`).
@@ -345,6 +345,31 @@ export class RelayTransport implements BridgeTransport {
345
345
  }
346
346
  }
347
347
 
348
+ /** THE MODULE LANE over the relay — same wire shape as `page-script`. */
349
+ async runGameScript(
350
+ src: string,
351
+ _step: (scope: unknown) => unknown,
352
+ instance?: string,
353
+ ): Promise<BridgeCallOutcome> {
354
+ try {
355
+ const body = await this.postCommand(
356
+ { type: 'game-eval', src, ...(instance === undefined ? {} : { instance }) },
357
+ PAGE_SCRIPT_TIMEOUT_MS,
358
+ );
359
+ return this.toBridgeOutcome(body);
360
+ } catch (err) {
361
+ return {
362
+ ok: false,
363
+ error: {
364
+ code: 'RELAY_UNREACHABLE',
365
+ message:
366
+ `vgai: could not reach the editor dev server relay at ${this.baseUrl} — ` +
367
+ `${err instanceof Error ? err.message : String(err)}`,
368
+ },
369
+ };
370
+ }
371
+ }
372
+
348
373
  /**
349
374
  * P20 — order the tab to reload, then wait for EVIDENCE that it came back.
350
375
  *