@vgai/live 0.5.41 → 0.5.44
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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/editor-document.d.ts +39 -2
- package/dist/editor-document.js +51 -2
- package/dist/editor.d.ts +142 -3
- package/dist/editor.js +162 -11
- package/dist/game-client/capture-notes.d.ts +26 -8
- package/dist/game-client/capture-notes.js +34 -7
- package/dist/game-client/client.d.ts +28 -2
- package/dist/game-client/client.js +80 -24
- package/dist/game-client/fast-forward.d.ts +5 -0
- package/dist/game-client/relay-transport.d.ts +1 -1
- package/dist/game-client/relay-transport.js +9 -6
- package/dist/game-client/screenshot-target.d.ts +17 -0
- package/dist/game-client/screenshot-target.js +22 -3
- package/dist/game.d.ts +2 -2
- package/dist/game.js +7 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/package.json +4 -3
- package/src/editor-document.ts +72 -2
- package/src/editor.ts +209 -5
- package/src/game-client/capture-notes.ts +49 -8
- package/src/game-client/client.ts +103 -14
- package/src/game-client/fast-forward.ts +5 -0
- package/src/game-client/relay-transport.ts +10 -5
- package/src/game-client/screenshot-target.ts +43 -3
- package/src/game.ts +20 -6
- package/src/index.ts +2 -2
|
@@ -2,17 +2,25 @@
|
|
|
2
2
|
* What a capture knows about ITSELF beyond its pixels — and the one place the
|
|
3
3
|
* caveat sentence is spelled.
|
|
4
4
|
*
|
|
5
|
-
* A PNG is silent about the conditions it was taken under.
|
|
6
|
-
* conditions
|
|
7
|
-
*
|
|
5
|
+
* A PNG is silent about the conditions it was taken under. Four of those
|
|
6
|
+
* conditions matter, and each is already measured elsewhere in the stack.
|
|
7
|
+
* Three change what the frame is worth as EVIDENCE: the editor page reports
|
|
8
8
|
* `loopRecoveryFrame` when the host loop was starved and the runtime had to
|
|
9
9
|
* render one deterministic tick on demand (`command-listener.ts`'s
|
|
10
10
|
* `handleBridgeScreenshot`), and it reports
|
|
11
11
|
* a `flatness.warning` sentence when the frame is nine-tenths one flat surface
|
|
12
|
-
* (`composite-screenshot.ts`'s `measureFlatness`)
|
|
12
|
+
* (`composite-screenshot.ts`'s `measureFlatness`), and it names the CLIP a
|
|
13
|
+
* frame of a recorded run belongs to (`command-listener.ts`'s
|
|
14
|
+
* `screenshotRecordingNotice`). Until now these stopped at a
|
|
13
15
|
* `console.warn` inside the relay transport — visible to a human watching a
|
|
14
16
|
* terminal, invisible to anything that later reads the file.
|
|
15
17
|
*
|
|
18
|
+
* The fourth is not about the frame at all but about what taking it COST: a
|
|
19
|
+
* capture written under the served project root goes through the dev server's
|
|
20
|
+
* file watcher on every shot (measured ~3x slower frame rates), and the
|
|
21
|
+
* out-path resolver is the only place that knows
|
|
22
|
+
* (`screenshot-target.ts`'s `underWatchedProjectRoot`).
|
|
23
|
+
*
|
|
16
24
|
* So the transport seam carries them back as {@link CaptureNotes}, and
|
|
17
25
|
* {@link describeCaptureCaveat} turns them into the ONE sentence every surface
|
|
18
26
|
* says. Its two callers are the transport's own console warning and
|
|
@@ -23,15 +31,27 @@
|
|
|
23
31
|
/** The loop-recovery sentence. Spelled once because it is said in two places
|
|
24
32
|
* (a live console warning and a persisted record) and a second copy is a
|
|
25
33
|
* second wording. */
|
|
34
|
+
/**
|
|
35
|
+
* The cost of writing a capture INSIDE the served project.
|
|
36
|
+
*
|
|
37
|
+
* The dev server watches the project root, and its ignore list names build
|
|
38
|
+
* output only — nothing about capture output — so every PNG written under the
|
|
39
|
+
* root goes through the watcher. Measured cost class: ~3x slower frame rates
|
|
40
|
+
* while a capture loop wrote there. Named, not fixed: where a capture goes is
|
|
41
|
+
* the caller's decision, and this door's job is to stop that decision being
|
|
42
|
+
* made blind.
|
|
43
|
+
*/
|
|
44
|
+
const WATCHED_CAPTURE_PATH_CAVEAT = 'writing captures under the project root triggers the dev server’s file watcher; expect ~3× ' +
|
|
45
|
+
'slower frame rates — write outside the project or to the session’s own capture dir';
|
|
26
46
|
const LOOP_RECOVERY_FRAME_CAVEAT = 'LOOP-RECOVERY FRAME — the host loop was starved, so the runtime rendered one ' +
|
|
27
47
|
'deterministic tick on demand. It is current, not stale; it was not produced by ordinary presentation.';
|
|
28
48
|
/**
|
|
29
49
|
* What a reader must be told about this frame, or `null` when there is nothing
|
|
30
50
|
* to tell.
|
|
31
51
|
*
|
|
32
|
-
*
|
|
33
|
-
* out near-blank), and
|
|
34
|
-
* must not report only the first reason.
|
|
52
|
+
* Any of them can be true at once (an on-demand recovery tick that also came
|
|
53
|
+
* out near-blank, in a recorded run), and each is said — a capture that is
|
|
54
|
+
* degraded twice over must not report only the first reason.
|
|
35
55
|
*/
|
|
36
56
|
export function describeCaptureCaveat(notes) {
|
|
37
57
|
const parts = [];
|
|
@@ -40,5 +60,12 @@ export function describeCaptureCaveat(notes) {
|
|
|
40
60
|
if (typeof notes.flatnessWarning === 'string' && notes.flatnessWarning !== '') {
|
|
41
61
|
parts.push(notes.flatnessWarning);
|
|
42
62
|
}
|
|
63
|
+
if (typeof notes.recordingPath === 'string' && notes.recordingPath !== '') {
|
|
64
|
+
parts.push(`ONE FRAME of a recorded run — ${notes.recordingPath} holds the whole of it. A still ` +
|
|
65
|
+
'answers what it looks like; a temporal question (did the jump land) needs the clip.');
|
|
66
|
+
}
|
|
67
|
+
if (typeof notes.watchedProjectRoot === 'string' && notes.watchedProjectRoot !== '') {
|
|
68
|
+
parts.push(`${WATCHED_CAPTURE_PATH_CAVEAT} (${notes.watchedProjectRoot}).`);
|
|
69
|
+
}
|
|
43
70
|
return parts.length === 0 ? null : parts.join(' ');
|
|
44
71
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Page } from '@playwright/test';
|
|
10
10
|
import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
|
|
11
11
|
import { type CaptureListener, type CaptureNotes } from './capture-notes.js';
|
|
12
|
-
import { type FastForwardBudget, type FastForwardOptions } from './fast-forward.js';
|
|
12
|
+
import { type FastForwardBudget, type FastForwardOptions, type FastForwardTime } from './fast-forward.js';
|
|
13
13
|
import { type TpsStats } from './perf-sampling.js';
|
|
14
14
|
import type { DebugCommandInfo, DebugSnapshot, ProviderInfo, VirtualActionValue } from './types.js';
|
|
15
15
|
import { type WaitForBudget } from './wait-for.js';
|
|
@@ -87,6 +87,11 @@ export interface GameClientOptions {
|
|
|
87
87
|
* actually running). */
|
|
88
88
|
fenceWallMs: number;
|
|
89
89
|
artifactsDir?: string | undefined;
|
|
90
|
+
/** The project root the editor session is SERVING, when the caller knows it
|
|
91
|
+
* (`@vgai/live`'s `connect()` does). Used for one thing: saying, in a
|
|
92
|
+
* capture's own notes, that its destination falls under the dev server's
|
|
93
|
+
* file watcher — see `capture-notes.ts`. Never used to resolve a path. */
|
|
94
|
+
projectRoot?: string | undefined;
|
|
90
95
|
/** Set by the caller when it reused an already-running game server rather
|
|
91
96
|
* than booting a fresh one for this run. Threaded through so `unwrap` can
|
|
92
97
|
* append the warm-session staleness hint to a
|
|
@@ -252,6 +257,9 @@ export declare class GameClient {
|
|
|
252
257
|
* AFTER the tps baseline reset below, so it never itself corrupts the tps
|
|
253
258
|
* stats either).
|
|
254
259
|
*/
|
|
260
|
+
fastForward(budget: FastForwardBudget, opts: FastForwardOptions & {
|
|
261
|
+
result: 'time';
|
|
262
|
+
}): Promise<FastForwardTime>;
|
|
255
263
|
fastForward(budget: FastForwardBudget, opts?: FastForwardOptions): Promise<DebugSnapshot>;
|
|
256
264
|
waitFor(pred: (s: (name: string) => unknown) => boolean, budget: WaitForBudget): Promise<void>;
|
|
257
265
|
/** Used by `input.hold` — waits for a sim-time delta to pass with no
|
|
@@ -329,10 +337,28 @@ export declare class GameClient {
|
|
|
329
337
|
* mount's own url space, so what you touch IS the running game — never a
|
|
330
338
|
* phantom second copy. Dev-server sessions only; a shipped build's curated
|
|
331
339
|
* surface is its adapter exports.
|
|
340
|
+
*
|
|
341
|
+
* `modules(path)` IS ASYNC — it dynamic-imports that url — so the callback
|
|
342
|
+
* is `async` and the call is `await`ed, in every example on this page and
|
|
343
|
+
* everywhere else. Skipping it does not fail quietly: reading a member off
|
|
344
|
+
* the unawaited promise throws a message naming the fix, because
|
|
345
|
+
* `TypeError: modules(...).simHost is not a function` (measured, cold fox
|
|
346
|
+
* #3) says nothing about promises.
|
|
347
|
+
*
|
|
348
|
+
* `modules` IS A FUNCTION, not a table — there is no module registry. To ask
|
|
349
|
+
* what the mount has loaded, read `modules.loaded`, the project-relative
|
|
350
|
+
* paths you can pass straight back in:
|
|
351
|
+
*
|
|
352
|
+
* ```js
|
|
353
|
+
* await game.run(({ modules }) => modules.loaded)
|
|
354
|
+
* // → ['src/scenes/MainScene.tsx', 'src/world.tsx', …]
|
|
355
|
+
* ```
|
|
332
356
|
*/
|
|
333
357
|
run<T = unknown>(step: (scope: {
|
|
334
358
|
page: Page;
|
|
335
|
-
modules: (path: string) => Promise<Record<string, unknown
|
|
359
|
+
modules: ((path: string) => Promise<Record<string, unknown>>) & {
|
|
360
|
+
readonly loaded: string[];
|
|
361
|
+
};
|
|
336
362
|
instanceId: string;
|
|
337
363
|
}) => T | Promise<T>, opts?: {
|
|
338
364
|
instance?: string;
|
|
@@ -77,6 +77,24 @@ async function bridgeCallInPageAsync(args) {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* A relay step crosses the wire as function source, so compiler-owned helpers that live beside the
|
|
82
|
+
* function in its Node module are closures too. esbuild's `keepNames` transform is the live case:
|
|
83
|
+
* a named helper inside an otherwise literal callback becomes `__name(fn, "helper")`, while the
|
|
84
|
+
* module-level `__name` implementation is absent after `step.toString()`. Seat that exact compiler
|
|
85
|
+
* primitive inside the serialized function instead of installing a page global or asking every
|
|
86
|
+
* caller to avoid ordinary named local helpers.
|
|
87
|
+
*
|
|
88
|
+
* Ordinary source stays byte-for-byte unchanged. That preserves the public wire account and keeps
|
|
89
|
+
* unsupported user closures loud; this only completes the source for a compiler helper whose
|
|
90
|
+
* semantics are intrinsic and deterministic.
|
|
91
|
+
*/
|
|
92
|
+
function selfContainedStepSource(step) {
|
|
93
|
+
const source = step.toString();
|
|
94
|
+
if (!/\b__name\s*\(/u.test(source))
|
|
95
|
+
return source;
|
|
96
|
+
return `(scope) => { const __name = (target, value) => Object.defineProperty(target, "name", { value, configurable: true }); return (${source})(scope); }`;
|
|
97
|
+
}
|
|
80
98
|
/**
|
|
81
99
|
* #140 — the `BridgeTransport` `page.evaluate` implementation. This is the
|
|
82
100
|
* ONE place a `Page` is ever touched to drive `window.__vgai` (module doc
|
|
@@ -436,6 +454,8 @@ export class GameClient {
|
|
|
436
454
|
#transport;
|
|
437
455
|
/** Where a labelled `screenshot()` lands — project-scoped by `@vgai/live`, cwd-relative otherwise. Public: a caller reading it is asking a fair question, and `screenshot()` returns a path under it anyway. */
|
|
438
456
|
artifactsDir;
|
|
457
|
+
/** See `GameClientOptions.projectRoot`. */
|
|
458
|
+
#projectRoot;
|
|
439
459
|
/** See `GameClientOptions.warmSession`. */
|
|
440
460
|
#warmSession;
|
|
441
461
|
/** See `GameClientOptions.testTitle`. */
|
|
@@ -466,6 +486,7 @@ export class GameClient {
|
|
|
466
486
|
this.fenceSimSeconds = opts.fenceSimSeconds;
|
|
467
487
|
this.fenceWallMs = opts.fenceWallMs;
|
|
468
488
|
this.artifactsDir = opts.artifactsDir ?? resolve('.vgai/last-run');
|
|
489
|
+
this.#projectRoot = opts.projectRoot;
|
|
469
490
|
this.#warmSession = opts.warmSession ?? false;
|
|
470
491
|
this.#testTitle = opts.testTitle ?? 'test';
|
|
471
492
|
this.#bridgeHeartbeat = { lastEmitWallMs: Date.now() };
|
|
@@ -527,18 +548,6 @@ export class GameClient {
|
|
|
527
548
|
hiddenRecoveryTriggered() {
|
|
528
549
|
return this.#hiddenRecovery.wasTriggered();
|
|
529
550
|
}
|
|
530
|
-
/**
|
|
531
|
-
* D15/T-D15.4 — synchronously drives `budget` worth of sim time/ticks via
|
|
532
|
-
* the live `Game`'s `runTicks` (through the debug bridge), instead of
|
|
533
|
-
* waiting for real wall-clock time to pass. Doctrine (see
|
|
534
|
-
* `fast-forward.ts`'s module doc, which also documents the two honesty
|
|
535
|
-
* decisions this wraps): SETUP/STAGING traversal — reaching a known
|
|
536
|
-
* late-game state fast — not a substitute for real-input proofs, which
|
|
537
|
-
* still run in real ticks. Returns the final `{time, state, events,
|
|
538
|
-
* pageErrors}` snapshot (a completely ordinary `snapshot()` read, taken
|
|
539
|
-
* AFTER the tps baseline reset below, so it never itself corrupts the tps
|
|
540
|
-
* stats either).
|
|
541
|
-
*/
|
|
542
551
|
async fastForward(budget, opts) {
|
|
543
552
|
// Same options-object-only contract as `waitSimTime` (`ticksForBudget`
|
|
544
553
|
// would otherwise fail on `'simTicks' in 0.5` with a raw TypeError that
|
|
@@ -570,21 +579,26 @@ export class GameClient {
|
|
|
570
579
|
}
|
|
571
580
|
},
|
|
572
581
|
readTime: async () => {
|
|
573
|
-
// Raw bridge read — deliberately NOT `this.snapshot()`,
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
582
|
+
// Raw, clock-only bridge read — deliberately NOT `this.snapshot()`,
|
|
583
|
+
// which both feeds the TpsAccumulator and serializes every declared
|
|
584
|
+
// provider. Large imported worlds can carry megabytes of census state;
|
|
585
|
+
// the batching math needs only these two numbers.
|
|
586
|
+
const time = await this.callBridge('state', 'time');
|
|
587
|
+
return { tick: time.tick, simSeconds: time.simSeconds };
|
|
577
588
|
},
|
|
578
589
|
heartbeat: (info) => {
|
|
579
590
|
console.log(`vgai fastForward: ${info.ticksDone}/${info.ticksTotal} ticks driven`);
|
|
580
591
|
},
|
|
581
592
|
};
|
|
582
|
-
await runFastForward(budget, opts ?? {}, clock);
|
|
593
|
+
const finalTime = await runFastForward(budget, opts ?? {}, clock);
|
|
583
594
|
// The burst is over — reset the baseline so the very next ordinary poll
|
|
584
595
|
// (including the `snapshot()` call right below) treats itself as a fresh
|
|
585
596
|
// "first observation" rather than diffing across the burst's enormous
|
|
586
597
|
// tick delta over a near-zero wall delta.
|
|
587
598
|
this.#tps.resetBaseline();
|
|
599
|
+
if (opts?.result === 'time') {
|
|
600
|
+
return finalTime;
|
|
601
|
+
}
|
|
588
602
|
return this.snapshot();
|
|
589
603
|
}
|
|
590
604
|
async waitFor(pred, budget) {
|
|
@@ -626,7 +640,13 @@ export class GameClient {
|
|
|
626
640
|
// fires either. Refuse in the caller's own vocabulary instead of hanging.
|
|
627
641
|
assertValidWaitForBudget(budget, 'waitSimTime');
|
|
628
642
|
const startWall = Date.now();
|
|
629
|
-
|
|
643
|
+
// Predicate-free means clock-only from the FIRST read, not merely from the
|
|
644
|
+
// second poll onward. A full initial snapshot serializes every provider;
|
|
645
|
+
// the Unity FPS provider alone carries ~4,500 objects and measured a
|
|
646
|
+
// 150-300ms main-thread hitch each time a caller began an otherwise cheap
|
|
647
|
+
// wait. Failure diagnostics still take one complete terminal snapshot
|
|
648
|
+
// below, only on the failure path where its state is actually printed.
|
|
649
|
+
const startTime = await this.readTime();
|
|
630
650
|
let lastTick = null;
|
|
631
651
|
let stalledPolls = 0;
|
|
632
652
|
// Fixture heartbeat — same invariant as
|
|
@@ -634,10 +654,10 @@ export class GameClient {
|
|
|
634
654
|
// silence AND the tick having advanced since the last one emitted, so a
|
|
635
655
|
// genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
|
|
636
656
|
// goes heartbeat-silent well before this loop's own guard ever needs to.
|
|
637
|
-
let heartbeat = { lastEmitWallMs: startWall, lastEmitTick:
|
|
657
|
+
let heartbeat = { lastEmitWallMs: startWall, lastEmitTick: startTime.tick };
|
|
638
658
|
for (;;) {
|
|
639
659
|
const currentTime = await this.readTime();
|
|
640
|
-
if (currentTime.simSeconds -
|
|
660
|
+
if (currentTime.simSeconds - startTime.simSeconds >= budget.simSeconds)
|
|
641
661
|
return;
|
|
642
662
|
stalledPolls = lastTick !== null && currentTime.tick === lastTick ? stalledPolls + 1 : 0;
|
|
643
663
|
lastTick = currentTime.tick;
|
|
@@ -647,7 +667,10 @@ export class GameClient {
|
|
|
647
667
|
const current = await this.snapshot();
|
|
648
668
|
throw await this.toSessionFailure(new WaitForTimeoutError({
|
|
649
669
|
budget,
|
|
650
|
-
|
|
670
|
+
// The timeout renderer reads only the start clock; provider state
|
|
671
|
+
// is intentionally terminal-only because no initial provider read
|
|
672
|
+
// occurred. Empty collections state that absence honestly.
|
|
673
|
+
startSnapshot: { time: startTime, state: {}, events: [], pageErrors: [] },
|
|
651
674
|
lastSnapshot: current,
|
|
652
675
|
wallElapsedMs: Date.now() - startWall,
|
|
653
676
|
predicateSource: '(no predicate — game.input.hold is waiting for a sim-time delta to pass)',
|
|
@@ -685,16 +708,33 @@ export class GameClient {
|
|
|
685
708
|
artifactsDir: this.artifactsDir,
|
|
686
709
|
sequence: this.#screenshotCounter + 1,
|
|
687
710
|
cwd: process.cwd(),
|
|
711
|
+
projectRoot: this.#projectRoot,
|
|
688
712
|
});
|
|
689
713
|
if (target.consumedSequence)
|
|
690
714
|
this.#screenshotCounter += 1;
|
|
691
715
|
await mkdir(dirname(target.path), { recursive: true });
|
|
692
|
-
const
|
|
716
|
+
const transportNotes = await this.#transport.screenshot(target.path);
|
|
717
|
+
// The out-path resolver is the only thing that knows the destination fell
|
|
718
|
+
// under the served project root, and the notes are where a capture says
|
|
719
|
+
// what it cost — so the two are joined here rather than at either end.
|
|
720
|
+
const notes = {
|
|
721
|
+
...transportNotes,
|
|
722
|
+
...(target.underWatchedProjectRoot === undefined
|
|
723
|
+
? {}
|
|
724
|
+
: { watchedProjectRoot: target.underWatchedProjectRoot }),
|
|
725
|
+
};
|
|
693
726
|
const capture = {
|
|
694
727
|
label: labelOrPath,
|
|
695
728
|
path: target.path,
|
|
696
729
|
caveat: describeCaptureCaveat(notes),
|
|
697
730
|
};
|
|
731
|
+
// ONE place says the sentence, with the WHOLE note set — a human watching
|
|
732
|
+
// this terminal and a run record read beside the frame must not be told
|
|
733
|
+
// different things (`capture-notes.ts`'s contract). The transport used to
|
|
734
|
+
// warn from its own partial set, which silently dropped every note this
|
|
735
|
+
// layer adds.
|
|
736
|
+
if (capture.caveat !== null)
|
|
737
|
+
console.warn(`vgai screenshot: ${capture.path} — ${capture.caveat}`);
|
|
698
738
|
// Sequential and AWAITED: a listener may need to read the game to stamp
|
|
699
739
|
// this capture, and it must have finished before the path is handed back —
|
|
700
740
|
// a caller that files the path is entitled to assume the record of it is
|
|
@@ -740,7 +780,7 @@ export class GameClient {
|
|
|
740
780
|
*/
|
|
741
781
|
async page(step) {
|
|
742
782
|
const erased = (arg) => step(arg);
|
|
743
|
-
const outcome = await this.#transport.runPageScript(step
|
|
783
|
+
const outcome = await this.#transport.runPageScript(selfContainedStepSource(step), erased);
|
|
744
784
|
return this.unwrap(outcome);
|
|
745
785
|
}
|
|
746
786
|
/**
|
|
@@ -760,10 +800,26 @@ export class GameClient {
|
|
|
760
800
|
* mount's own url space, so what you touch IS the running game — never a
|
|
761
801
|
* phantom second copy. Dev-server sessions only; a shipped build's curated
|
|
762
802
|
* surface is its adapter exports.
|
|
803
|
+
*
|
|
804
|
+
* `modules(path)` IS ASYNC — it dynamic-imports that url — so the callback
|
|
805
|
+
* is `async` and the call is `await`ed, in every example on this page and
|
|
806
|
+
* everywhere else. Skipping it does not fail quietly: reading a member off
|
|
807
|
+
* the unawaited promise throws a message naming the fix, because
|
|
808
|
+
* `TypeError: modules(...).simHost is not a function` (measured, cold fox
|
|
809
|
+
* #3) says nothing about promises.
|
|
810
|
+
*
|
|
811
|
+
* `modules` IS A FUNCTION, not a table — there is no module registry. To ask
|
|
812
|
+
* what the mount has loaded, read `modules.loaded`, the project-relative
|
|
813
|
+
* paths you can pass straight back in:
|
|
814
|
+
*
|
|
815
|
+
* ```js
|
|
816
|
+
* await game.run(({ modules }) => modules.loaded)
|
|
817
|
+
* // → ['src/scenes/MainScene.tsx', 'src/world.tsx', …]
|
|
818
|
+
* ```
|
|
763
819
|
*/
|
|
764
820
|
async run(step, opts) {
|
|
765
821
|
const erased = (arg) => step(arg);
|
|
766
|
-
const outcome = await this.#transport.runGameScript(step
|
|
822
|
+
const outcome = await this.#transport.runGameScript(selfContainedStepSource(step), erased, opts?.instance);
|
|
767
823
|
return this.unwrap(outcome);
|
|
768
824
|
}
|
|
769
825
|
/**
|
|
@@ -64,6 +64,11 @@ export interface FastForwardOptions {
|
|
|
64
64
|
/** Overrides `DEFAULT_FAST_FORWARD_BATCH_TICKS` — test-only seam; real
|
|
65
65
|
* callers should leave this unset. */
|
|
66
66
|
batchTicks?: number;
|
|
67
|
+
/** What the caller needs back after the burst. Defaults to the complete
|
|
68
|
+
* debug snapshot. Tick-by-tick orchestration that samples providers only at
|
|
69
|
+
* explicit boundaries selects `'time'` so it does not serialize every large
|
|
70
|
+
* state provider after every staging tick. */
|
|
71
|
+
result?: 'snapshot' | 'time';
|
|
67
72
|
}
|
|
68
73
|
/** One fixed timestep, matching every real host's loop construction
|
|
69
74
|
* (`createGameLoop`'s own `fixedTimestep ?? 1/60` default: "fixedDt = the
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* anywhere in this path).
|
|
20
20
|
*/
|
|
21
21
|
import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
|
|
22
|
-
import {
|
|
22
|
+
import type { CaptureNotes } from './capture-notes.js';
|
|
23
23
|
export interface RelayTransportOptions {
|
|
24
24
|
/** The live editor session's dev-server port (e.g. from
|
|
25
25
|
* `findLiveEditorSession`/`vgai edit`). */
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
22
22
|
import { dirname } from 'node:path';
|
|
23
|
-
import { describeCaptureCaveat } from './capture-notes.js';
|
|
24
23
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
25
24
|
/** `invoke` dispatches to arbitrary game-registered debug commands — give it
|
|
26
25
|
* real headroom rather than the ordinary sync-call budget above. */
|
|
@@ -236,16 +235,20 @@ export class RelayTransport {
|
|
|
236
235
|
// is indistinguishable from a good one until somebody opens the file —
|
|
237
236
|
// which is exactly how two probes cited blank captures as evidence. The
|
|
238
237
|
// page measured both conditions; carry them back so the caller can persist
|
|
239
|
-
// them beside the frame
|
|
240
|
-
//
|
|
238
|
+
// them beside the frame.
|
|
239
|
+
//
|
|
240
|
+
// The SENTENCE is not said here. `GameClient.screenshot` composes it from
|
|
241
|
+
// these notes PLUS the ones only it has (the out-path's relation to the
|
|
242
|
+
// served project root), and `capture-notes.ts`'s whole contract is that
|
|
243
|
+
// the terminal and the persisted record cannot drift — which a second
|
|
244
|
+
// `console.warn` on a partial set is exactly how they would.
|
|
241
245
|
const warning = body['flatness']?.warning;
|
|
246
|
+
const recordingPath = body['recording']?.path;
|
|
242
247
|
const notes = {
|
|
243
248
|
...(body['loopRecoveryFrame'] === true ? { loopRecoveryFrame: true } : {}),
|
|
244
249
|
...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
|
|
250
|
+
...(typeof recordingPath === 'string' ? { recordingPath } : {}),
|
|
245
251
|
};
|
|
246
|
-
const caveat = describeCaptureCaveat(notes);
|
|
247
|
-
if (caveat !== null)
|
|
248
|
-
console.warn(`vgai screenshot: ${path} — ${caveat}`);
|
|
249
252
|
return notes;
|
|
250
253
|
}
|
|
251
254
|
/**
|
|
@@ -43,6 +43,10 @@ export interface ScreenshotTargetInput {
|
|
|
43
43
|
readonly sequence: number;
|
|
44
44
|
/** Base for resolving a relative path/artifactsDir — the process cwd. */
|
|
45
45
|
readonly cwd: string;
|
|
46
|
+
/** The project root the dev server is SERVING, when it is known. Only used
|
|
47
|
+
* to say whether the destination lands under the file watcher — see
|
|
48
|
+
* {@link ScreenshotTarget.underWatchedProjectRoot}. */
|
|
49
|
+
readonly projectRoot?: string | undefined;
|
|
46
50
|
}
|
|
47
51
|
export interface ScreenshotTarget {
|
|
48
52
|
readonly kind: ScreenshotArgKind;
|
|
@@ -51,6 +55,19 @@ export interface ScreenshotTarget {
|
|
|
51
55
|
/** True when the caller's own ordinal was consumed (label form only), so a
|
|
52
56
|
* path-form call never perturbs the numbering of the artifacts around it. */
|
|
53
57
|
readonly consumedSequence: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* The served project root this destination falls under, when it does.
|
|
60
|
+
*
|
|
61
|
+
* The dev server watches the project root for source changes and its
|
|
62
|
+
* `server.watch.ignored` list names build output only (`dist/`, `.vercel/`,
|
|
63
|
+
* `logs/`, `.claude/worktrees/`) — nothing about capture output. So a PNG
|
|
64
|
+
* written anywhere under the root goes through the watcher on every shot,
|
|
65
|
+
* and the cost is not theoretical: measured ~3x slower frame rates while a
|
|
66
|
+
* capture loop wrote under the project. This does not CHANGE where anything
|
|
67
|
+
* is written; it lets the capture say what it costs
|
|
68
|
+
* ({@link import('./capture-notes.js').describeCaptureCaveat}).
|
|
69
|
+
*/
|
|
70
|
+
readonly underWatchedProjectRoot?: string;
|
|
54
71
|
}
|
|
55
72
|
/**
|
|
56
73
|
* Resolve the absolute file a screenshot call must write. Pure — no I/O, no
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* resolved against the process cwd — the same rule `vgai screenshot --out`
|
|
20
20
|
* already documents.
|
|
21
21
|
*/
|
|
22
|
-
import { isAbsolute, resolve } from 'node:path';
|
|
22
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
23
23
|
/**
|
|
24
24
|
* PATH when the argument names a location: absolute, containing a `/` or `\`
|
|
25
25
|
* separator, an explicit `./`-style relative prefix, or carrying a file
|
|
@@ -45,6 +45,17 @@ export function classifyScreenshotArg(arg) {
|
|
|
45
45
|
export function sanitizeScreenshotLabel(label) {
|
|
46
46
|
return label.replace(/[^a-zA-Z0-9-_]+/g, '-');
|
|
47
47
|
}
|
|
48
|
+
/** Is `file` inside `root` (not merely sharing a path prefix)? */
|
|
49
|
+
function isUnder(root, file) {
|
|
50
|
+
const rel = relative(resolve(root), file);
|
|
51
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
52
|
+
}
|
|
53
|
+
/** The watched-root note, when the destination has one. */
|
|
54
|
+
function watchedRoot(path, projectRoot) {
|
|
55
|
+
if (projectRoot === undefined || projectRoot === '')
|
|
56
|
+
return {};
|
|
57
|
+
return isUnder(projectRoot, path) ? { underWatchedProjectRoot: resolve(projectRoot) } : {};
|
|
58
|
+
}
|
|
48
59
|
/**
|
|
49
60
|
* Resolve the absolute file a screenshot call must write. Pure — no I/O, no
|
|
50
61
|
* `process.cwd()` read — so the contract above is testable without a browser,
|
|
@@ -54,15 +65,23 @@ export function resolveScreenshotTarget(input) {
|
|
|
54
65
|
const kind = classifyScreenshotArg(input.arg);
|
|
55
66
|
if (kind === 'path') {
|
|
56
67
|
const withExtension = /\.[a-zA-Z0-9]{1,8}$/.test(input.arg) ? input.arg : `${input.arg}.png`;
|
|
68
|
+
const path = isAbsolute(withExtension) ? withExtension : resolve(input.cwd, withExtension);
|
|
57
69
|
return {
|
|
58
70
|
kind,
|
|
59
|
-
path
|
|
71
|
+
path,
|
|
60
72
|
consumedSequence: false,
|
|
73
|
+
...watchedRoot(path, input.projectRoot),
|
|
61
74
|
};
|
|
62
75
|
}
|
|
63
76
|
const fileName = `${String(input.sequence).padStart(3, '0')}-${sanitizeScreenshotLabel(input.arg)}.png`;
|
|
64
77
|
const dir = isAbsolute(input.artifactsDir)
|
|
65
78
|
? input.artifactsDir
|
|
66
79
|
: resolve(input.cwd, input.artifactsDir);
|
|
67
|
-
|
|
80
|
+
const path = resolve(dir, fileName);
|
|
81
|
+
return {
|
|
82
|
+
kind,
|
|
83
|
+
path,
|
|
84
|
+
consumedSequence: true,
|
|
85
|
+
...watchedRoot(path, input.projectRoot),
|
|
86
|
+
};
|
|
68
87
|
}
|
package/dist/game.d.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface LiveGame extends GameClient {
|
|
|
52
52
|
/** The unaddressed `game` client — targets the sole live instance and refuses
|
|
53
53
|
* when several are mounted. Kept as a named export for callers/tests that
|
|
54
54
|
* want just the base client. */
|
|
55
|
-
export declare function createGameClient(port: number, artifactsDir?: string): GameClient;
|
|
55
|
+
export declare function createGameClient(port: number, artifactsDir?: string, projectRoot?: string): GameClient;
|
|
56
56
|
/** Build the `LiveGame` — the base `game` client plus its instance-addressing
|
|
57
57
|
* surface. */
|
|
58
|
-
export declare function createLiveGame(port: number, artifactsDir?: string): LiveGame;
|
|
58
|
+
export declare function createLiveGame(port: number, artifactsDir?: string, projectRoot?: string): LiveGame;
|
package/dist/game.js
CHANGED
|
@@ -32,7 +32,7 @@ import { GameClient, RelayTransport } from './game-client/index.js';
|
|
|
32
32
|
* fence. `pageErrors`/`consoleErrors` are always empty — relay mode has no
|
|
33
33
|
* separate page handle to listen on.
|
|
34
34
|
*/
|
|
35
|
-
function gameClientFor(port, artifactsDir, instance) {
|
|
35
|
+
function gameClientFor(port, artifactsDir, instance, projectRoot) {
|
|
36
36
|
return new GameClient({
|
|
37
37
|
transport: new RelayTransport(instance === undefined ? { port } : { port, instance }),
|
|
38
38
|
pageErrors: [],
|
|
@@ -43,13 +43,14 @@ function gameClientFor(port, artifactsDir, instance) {
|
|
|
43
43
|
fenceWallMs: Date.now(),
|
|
44
44
|
warmSession: false,
|
|
45
45
|
artifactsDir,
|
|
46
|
+
projectRoot,
|
|
46
47
|
});
|
|
47
48
|
}
|
|
48
49
|
/** The unaddressed `game` client — targets the sole live instance and refuses
|
|
49
50
|
* when several are mounted. Kept as a named export for callers/tests that
|
|
50
51
|
* want just the base client. */
|
|
51
|
-
export function createGameClient(port, artifactsDir) {
|
|
52
|
-
return gameClientFor(port, artifactsDir);
|
|
52
|
+
export function createGameClient(port, artifactsDir, projectRoot) {
|
|
53
|
+
return gameClientFor(port, artifactsDir, undefined, projectRoot);
|
|
53
54
|
}
|
|
54
55
|
/** Query the editor's live instance ids over the session wire.
|
|
55
56
|
*
|
|
@@ -73,12 +74,12 @@ async function listInstanceIds(port) {
|
|
|
73
74
|
}
|
|
74
75
|
/** Build the `LiveGame` — the base `game` client plus its instance-addressing
|
|
75
76
|
* surface. */
|
|
76
|
-
export function createLiveGame(port, artifactsDir) {
|
|
77
|
-
const base = gameClientFor(port, artifactsDir);
|
|
77
|
+
export function createLiveGame(port, artifactsDir, projectRoot) {
|
|
78
|
+
const base = gameClientFor(port, artifactsDir, undefined, projectRoot);
|
|
78
79
|
// Tag each addressed handle with the mount id it drives, so a caller can pass
|
|
79
80
|
// `handle.id` to `tools.run(..., { instance })`. The id is already known here
|
|
80
81
|
// (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 instance = (id) => Object.assign(gameClientFor(port, artifactsDir, id, projectRoot), { id });
|
|
82
83
|
const instances = async () => (await listInstanceIds(port)).map(instance);
|
|
83
84
|
return Object.assign(base, { instance, instances });
|
|
84
85
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*
|
|
30
30
|
* `editor.document` is the OTHER surface door and the one that is NOT
|
|
31
31
|
* play-mode gated: `page(step)` is rooted at the running GAME container, so
|
|
32
|
-
* `editor.document.{query,click,key,paste}` is how an editor surface that is
|
|
32
|
+
* `editor.document.{query,click,key,paste,select}` is how an editor surface that is
|
|
33
33
|
* not a game — a capability's workspace document, the Data sheet — gets read
|
|
34
34
|
* and driven through the product. It is scoped to the ACTIVE document and
|
|
35
35
|
* refuses anything outside it by name (`editor-document.ts`).
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*
|
|
30
30
|
* `editor.document` is the OTHER surface door and the one that is NOT
|
|
31
31
|
* play-mode gated: `page(step)` is rooted at the running GAME container, so
|
|
32
|
-
* `editor.document.{query,click,key,paste}` is how an editor surface that is
|
|
32
|
+
* `editor.document.{query,click,key,paste,select}` is how an editor surface that is
|
|
33
33
|
* not a game — a capability's workspace document, the Data sheet — gets read
|
|
34
34
|
* and driven through the product. It is scoped to the ACTIVE document and
|
|
35
35
|
* refuses anything outside it by name (`editor-document.ts`).
|
|
@@ -67,7 +67,7 @@ export { LiveTools } from './tools.js';
|
|
|
67
67
|
function bindTo(port, projectRoot) {
|
|
68
68
|
const client = new EditorClient({ url: `http://127.0.0.1:${port}` });
|
|
69
69
|
const editor = new LiveEditor(client);
|
|
70
|
-
const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'));
|
|
70
|
+
const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'), projectRoot);
|
|
71
71
|
const step = (fn) => game.page(fn);
|
|
72
72
|
const page = Object.assign(step, { reload: () => game.reloadPage() });
|
|
73
73
|
const tools = new LiveTools(client);
|
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.
|
|
5
|
+
"version": "0.5.44",
|
|
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.
|
|
36
|
-
"@vgai/sdk": "0.5.
|
|
35
|
+
"@vgai/editor-sdk": "0.5.44",
|
|
36
|
+
"@vgai/sdk": "0.5.44"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@playwright/test": ">=1.58.2 <2"
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@playwright/test": "^1.58.2",
|
|
43
43
|
"@types/node": "^25.3.0",
|
|
44
|
+
"esbuild": "^0.25.12",
|
|
44
45
|
"typescript": "^5.6.0"
|
|
45
46
|
},
|
|
46
47
|
"description": "Live-session client for VGAI projects: { editor, game, page, tools } over the vgai edit session wire.",
|