@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
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type FastForwardBudget,
|
|
21
21
|
type FastForwardClock,
|
|
22
22
|
type FastForwardOptions,
|
|
23
|
+
type FastForwardTime,
|
|
23
24
|
runFastForward,
|
|
24
25
|
} from './fast-forward.js';
|
|
25
26
|
import { HiddenRecoveryDriver } from './hidden-recovery.js';
|
|
@@ -104,6 +105,24 @@ async function bridgeCallInPageAsync(args: {
|
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A relay step crosses the wire as function source, so compiler-owned helpers that live beside the
|
|
110
|
+
* function in its Node module are closures too. esbuild's `keepNames` transform is the live case:
|
|
111
|
+
* a named helper inside an otherwise literal callback becomes `__name(fn, "helper")`, while the
|
|
112
|
+
* module-level `__name` implementation is absent after `step.toString()`. Seat that exact compiler
|
|
113
|
+
* primitive inside the serialized function instead of installing a page global or asking every
|
|
114
|
+
* caller to avoid ordinary named local helpers.
|
|
115
|
+
*
|
|
116
|
+
* Ordinary source stays byte-for-byte unchanged. That preserves the public wire account and keeps
|
|
117
|
+
* unsupported user closures loud; this only completes the source for a compiler helper whose
|
|
118
|
+
* semantics are intrinsic and deterministic.
|
|
119
|
+
*/
|
|
120
|
+
function selfContainedStepSource(step: (scope: unknown) => unknown): string {
|
|
121
|
+
const source = step.toString();
|
|
122
|
+
if (!/\b__name\s*\(/u.test(source)) return source;
|
|
123
|
+
return `(scope) => { const __name = (target, value) => Object.defineProperty(target, "name", { value, configurable: true }); return (${source})(scope); }`;
|
|
124
|
+
}
|
|
125
|
+
|
|
107
126
|
/**
|
|
108
127
|
* #140 — the `BridgeTransport` `page.evaluate` implementation. This is the
|
|
109
128
|
* ONE place a `Page` is ever touched to drive `window.__vgai` (module doc
|
|
@@ -237,6 +256,11 @@ export interface GameClientOptions {
|
|
|
237
256
|
* actually running). */
|
|
238
257
|
fenceWallMs: number;
|
|
239
258
|
artifactsDir?: string | undefined;
|
|
259
|
+
/** The project root the editor session is SERVING, when the caller knows it
|
|
260
|
+
* (`@vgai/live`'s `connect()` does). Used for one thing: saying, in a
|
|
261
|
+
* capture's own notes, that its destination falls under the dev server's
|
|
262
|
+
* file watcher — see `capture-notes.ts`. Never used to resolve a path. */
|
|
263
|
+
projectRoot?: string | undefined;
|
|
240
264
|
/** Set by the caller when it reused an already-running game server rather
|
|
241
265
|
* than booting a fresh one for this run. Threaded through so `unwrap` can
|
|
242
266
|
* append the warm-session staleness hint to a
|
|
@@ -553,6 +577,8 @@ export class GameClient {
|
|
|
553
577
|
readonly #transport: BridgeTransport;
|
|
554
578
|
/** 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. */
|
|
555
579
|
readonly artifactsDir: string;
|
|
580
|
+
/** See `GameClientOptions.projectRoot`. */
|
|
581
|
+
readonly #projectRoot: string | undefined;
|
|
556
582
|
/** See `GameClientOptions.warmSession`. */
|
|
557
583
|
readonly #warmSession: boolean;
|
|
558
584
|
/** See `GameClientOptions.testTitle`. */
|
|
@@ -584,6 +610,7 @@ export class GameClient {
|
|
|
584
610
|
this.fenceSimSeconds = opts.fenceSimSeconds;
|
|
585
611
|
this.fenceWallMs = opts.fenceWallMs;
|
|
586
612
|
this.artifactsDir = opts.artifactsDir ?? resolve('.vgai/last-run');
|
|
613
|
+
this.#projectRoot = opts.projectRoot;
|
|
587
614
|
this.#warmSession = opts.warmSession ?? false;
|
|
588
615
|
this.#testTitle = opts.testTitle ?? 'test';
|
|
589
616
|
this.#bridgeHeartbeat = { lastEmitWallMs: Date.now() };
|
|
@@ -666,7 +693,15 @@ export class GameClient {
|
|
|
666
693
|
* AFTER the tps baseline reset below, so it never itself corrupts the tps
|
|
667
694
|
* stats either).
|
|
668
695
|
*/
|
|
669
|
-
async fastForward(
|
|
696
|
+
async fastForward(
|
|
697
|
+
budget: FastForwardBudget,
|
|
698
|
+
opts: FastForwardOptions & { result: 'time' },
|
|
699
|
+
): Promise<FastForwardTime>;
|
|
700
|
+
async fastForward(budget: FastForwardBudget, opts?: FastForwardOptions): Promise<DebugSnapshot>;
|
|
701
|
+
async fastForward(
|
|
702
|
+
budget: FastForwardBudget,
|
|
703
|
+
opts?: FastForwardOptions,
|
|
704
|
+
): Promise<DebugSnapshot | FastForwardTime> {
|
|
670
705
|
// Same options-object-only contract as `waitSimTime` (`ticksForBudget`
|
|
671
706
|
// would otherwise fail on `'simTicks' in 0.5` with a raw TypeError that
|
|
672
707
|
// names neither the method nor the shape).
|
|
@@ -696,21 +731,26 @@ export class GameClient {
|
|
|
696
731
|
}
|
|
697
732
|
},
|
|
698
733
|
readTime: async () => {
|
|
699
|
-
// Raw bridge read — deliberately NOT `this.snapshot()`,
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
|
|
734
|
+
// Raw, clock-only bridge read — deliberately NOT `this.snapshot()`,
|
|
735
|
+
// which both feeds the TpsAccumulator and serializes every declared
|
|
736
|
+
// provider. Large imported worlds can carry megabytes of census state;
|
|
737
|
+
// the batching math needs only these two numbers.
|
|
738
|
+
const time = await this.callBridge<DebugSnapshot['time']>('state', 'time');
|
|
739
|
+
return { tick: time.tick, simSeconds: time.simSeconds };
|
|
703
740
|
},
|
|
704
741
|
heartbeat: (info) => {
|
|
705
742
|
console.log(`vgai fastForward: ${info.ticksDone}/${info.ticksTotal} ticks driven`);
|
|
706
743
|
},
|
|
707
744
|
};
|
|
708
|
-
await runFastForward(budget, opts ?? {}, clock);
|
|
745
|
+
const finalTime = await runFastForward(budget, opts ?? {}, clock);
|
|
709
746
|
// The burst is over — reset the baseline so the very next ordinary poll
|
|
710
747
|
// (including the `snapshot()` call right below) treats itself as a fresh
|
|
711
748
|
// "first observation" rather than diffing across the burst's enormous
|
|
712
749
|
// tick delta over a near-zero wall delta.
|
|
713
750
|
this.#tps.resetBaseline();
|
|
751
|
+
if (opts?.result === 'time') {
|
|
752
|
+
return finalTime;
|
|
753
|
+
}
|
|
714
754
|
return this.snapshot();
|
|
715
755
|
}
|
|
716
756
|
|
|
@@ -760,7 +800,13 @@ export class GameClient {
|
|
|
760
800
|
// fires either. Refuse in the caller's own vocabulary instead of hanging.
|
|
761
801
|
assertValidWaitForBudget(budget, 'waitSimTime');
|
|
762
802
|
const startWall = Date.now();
|
|
763
|
-
|
|
803
|
+
// Predicate-free means clock-only from the FIRST read, not merely from the
|
|
804
|
+
// second poll onward. A full initial snapshot serializes every provider;
|
|
805
|
+
// the Unity FPS provider alone carries ~4,500 objects and measured a
|
|
806
|
+
// 150-300ms main-thread hitch each time a caller began an otherwise cheap
|
|
807
|
+
// wait. Failure diagnostics still take one complete terminal snapshot
|
|
808
|
+
// below, only on the failure path where its state is actually printed.
|
|
809
|
+
const startTime = await this.readTime();
|
|
764
810
|
let lastTick: number | null = null;
|
|
765
811
|
let stalledPolls = 0;
|
|
766
812
|
// Fixture heartbeat — same invariant as
|
|
@@ -768,10 +814,10 @@ export class GameClient {
|
|
|
768
814
|
// silence AND the tick having advanced since the last one emitted, so a
|
|
769
815
|
// genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
|
|
770
816
|
// goes heartbeat-silent well before this loop's own guard ever needs to.
|
|
771
|
-
let heartbeat: HeartbeatState = { lastEmitWallMs: startWall, lastEmitTick:
|
|
817
|
+
let heartbeat: HeartbeatState = { lastEmitWallMs: startWall, lastEmitTick: startTime.tick };
|
|
772
818
|
for (;;) {
|
|
773
819
|
const currentTime = await this.readTime();
|
|
774
|
-
if (currentTime.simSeconds -
|
|
820
|
+
if (currentTime.simSeconds - startTime.simSeconds >= budget.simSeconds) return;
|
|
775
821
|
stalledPolls = lastTick !== null && currentTime.tick === lastTick ? stalledPolls + 1 : 0;
|
|
776
822
|
lastTick = currentTime.tick;
|
|
777
823
|
if (stalledPolls >= WAIT_FOR_STALL_POLL_LIMIT) {
|
|
@@ -781,7 +827,10 @@ export class GameClient {
|
|
|
781
827
|
throw await this.toSessionFailure(
|
|
782
828
|
new WaitForTimeoutError({
|
|
783
829
|
budget,
|
|
784
|
-
|
|
830
|
+
// The timeout renderer reads only the start clock; provider state
|
|
831
|
+
// is intentionally terminal-only because no initial provider read
|
|
832
|
+
// occurred. Empty collections state that absence honestly.
|
|
833
|
+
startSnapshot: { time: startTime, state: {}, events: [], pageErrors: [] },
|
|
785
834
|
lastSnapshot: current,
|
|
786
835
|
wallElapsedMs: Date.now() - startWall,
|
|
787
836
|
predicateSource:
|
|
@@ -821,15 +870,32 @@ export class GameClient {
|
|
|
821
870
|
artifactsDir: this.artifactsDir,
|
|
822
871
|
sequence: this.#screenshotCounter + 1,
|
|
823
872
|
cwd: process.cwd(),
|
|
873
|
+
projectRoot: this.#projectRoot,
|
|
824
874
|
});
|
|
825
875
|
if (target.consumedSequence) this.#screenshotCounter += 1;
|
|
826
876
|
await mkdir(dirname(target.path), { recursive: true });
|
|
827
|
-
const
|
|
877
|
+
const transportNotes = await this.#transport.screenshot(target.path);
|
|
878
|
+
// The out-path resolver is the only thing that knows the destination fell
|
|
879
|
+
// under the served project root, and the notes are where a capture says
|
|
880
|
+
// what it cost — so the two are joined here rather than at either end.
|
|
881
|
+
const notes: CaptureNotes = {
|
|
882
|
+
...transportNotes,
|
|
883
|
+
...(target.underWatchedProjectRoot === undefined
|
|
884
|
+
? {}
|
|
885
|
+
: { watchedProjectRoot: target.underWatchedProjectRoot }),
|
|
886
|
+
};
|
|
828
887
|
const capture = {
|
|
829
888
|
label: labelOrPath,
|
|
830
889
|
path: target.path,
|
|
831
890
|
caveat: describeCaptureCaveat(notes),
|
|
832
891
|
};
|
|
892
|
+
// ONE place says the sentence, with the WHOLE note set — a human watching
|
|
893
|
+
// this terminal and a run record read beside the frame must not be told
|
|
894
|
+
// different things (`capture-notes.ts`'s contract). The transport used to
|
|
895
|
+
// warn from its own partial set, which silently dropped every note this
|
|
896
|
+
// layer adds.
|
|
897
|
+
if (capture.caveat !== null)
|
|
898
|
+
console.warn(`vgai screenshot: ${capture.path} — ${capture.caveat}`);
|
|
833
899
|
// Sequential and AWAITED: a listener may need to read the game to stamp
|
|
834
900
|
// this capture, and it must have finished before the path is handed back —
|
|
835
901
|
// a caller that files the path is entitled to assume the record of it is
|
|
@@ -876,7 +942,10 @@ export class GameClient {
|
|
|
876
942
|
*/
|
|
877
943
|
async page<T = unknown>(step: (page: Page) => T | Promise<T>): Promise<T> {
|
|
878
944
|
const erased = (arg: unknown) => step(arg as Page);
|
|
879
|
-
const outcome = await this.#transport.runPageScript(
|
|
945
|
+
const outcome = await this.#transport.runPageScript(
|
|
946
|
+
selfContainedStepSource(step as (scope: unknown) => unknown),
|
|
947
|
+
erased,
|
|
948
|
+
);
|
|
880
949
|
return this.unwrap<T>(outcome);
|
|
881
950
|
}
|
|
882
951
|
|
|
@@ -897,17 +966,37 @@ export class GameClient {
|
|
|
897
966
|
* mount's own url space, so what you touch IS the running game — never a
|
|
898
967
|
* phantom second copy. Dev-server sessions only; a shipped build's curated
|
|
899
968
|
* surface is its adapter exports.
|
|
969
|
+
*
|
|
970
|
+
* `modules(path)` IS ASYNC — it dynamic-imports that url — so the callback
|
|
971
|
+
* is `async` and the call is `await`ed, in every example on this page and
|
|
972
|
+
* everywhere else. Skipping it does not fail quietly: reading a member off
|
|
973
|
+
* the unawaited promise throws a message naming the fix, because
|
|
974
|
+
* `TypeError: modules(...).simHost is not a function` (measured, cold fox
|
|
975
|
+
* #3) says nothing about promises.
|
|
976
|
+
*
|
|
977
|
+
* `modules` IS A FUNCTION, not a table — there is no module registry. To ask
|
|
978
|
+
* what the mount has loaded, read `modules.loaded`, the project-relative
|
|
979
|
+
* paths you can pass straight back in:
|
|
980
|
+
*
|
|
981
|
+
* ```js
|
|
982
|
+
* await game.run(({ modules }) => modules.loaded)
|
|
983
|
+
* // → ['src/scenes/MainScene.tsx', 'src/world.tsx', …]
|
|
984
|
+
* ```
|
|
900
985
|
*/
|
|
901
986
|
async run<T = unknown>(
|
|
902
987
|
step: (scope: {
|
|
903
988
|
page: Page;
|
|
904
|
-
modules: (path: string) => Promise<Record<string, unknown
|
|
989
|
+
modules: ((path: string) => Promise<Record<string, unknown>>) & { readonly loaded: string[] };
|
|
905
990
|
instanceId: string;
|
|
906
991
|
}) => T | Promise<T>,
|
|
907
992
|
opts?: { instance?: string },
|
|
908
993
|
): Promise<T> {
|
|
909
994
|
const erased = (arg: unknown) => step(arg as Parameters<typeof step>[0]);
|
|
910
|
-
const outcome = await this.#transport.runGameScript(
|
|
995
|
+
const outcome = await this.#transport.runGameScript(
|
|
996
|
+
selfContainedStepSource(step as (scope: unknown) => unknown),
|
|
997
|
+
erased,
|
|
998
|
+
opts?.instance,
|
|
999
|
+
);
|
|
911
1000
|
return this.unwrap<T>(outcome);
|
|
912
1001
|
}
|
|
913
1002
|
|
|
@@ -63,6 +63,11 @@ export interface FastForwardOptions {
|
|
|
63
63
|
/** Overrides `DEFAULT_FAST_FORWARD_BATCH_TICKS` — test-only seam; real
|
|
64
64
|
* callers should leave this unset. */
|
|
65
65
|
batchTicks?: number;
|
|
66
|
+
/** What the caller needs back after the burst. Defaults to the complete
|
|
67
|
+
* debug snapshot. Tick-by-tick orchestration that samples providers only at
|
|
68
|
+
* explicit boundaries selects `'time'` so it does not serialize every large
|
|
69
|
+
* state provider after every staging tick. */
|
|
70
|
+
result?: 'snapshot' | 'time';
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
/** One fixed timestep, matching every real host's loop construction
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
23
23
|
import { dirname } from 'node:path';
|
|
24
24
|
import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
|
|
25
|
-
import {
|
|
25
|
+
import type { CaptureNotes } from './capture-notes.js';
|
|
26
26
|
|
|
27
27
|
export interface RelayTransportOptions {
|
|
28
28
|
/** The live editor session's dev-server port (e.g. from
|
|
@@ -310,15 +310,20 @@ export class RelayTransport implements BridgeTransport {
|
|
|
310
310
|
// is indistinguishable from a good one until somebody opens the file —
|
|
311
311
|
// which is exactly how two probes cited blank captures as evidence. The
|
|
312
312
|
// page measured both conditions; carry them back so the caller can persist
|
|
313
|
-
// them beside the frame
|
|
314
|
-
//
|
|
313
|
+
// them beside the frame.
|
|
314
|
+
//
|
|
315
|
+
// The SENTENCE is not said here. `GameClient.screenshot` composes it from
|
|
316
|
+
// these notes PLUS the ones only it has (the out-path's relation to the
|
|
317
|
+
// served project root), and `capture-notes.ts`'s whole contract is that
|
|
318
|
+
// the terminal and the persisted record cannot drift — which a second
|
|
319
|
+
// `console.warn` on a partial set is exactly how they would.
|
|
315
320
|
const warning = (body['flatness'] as { warning?: string } | undefined)?.warning;
|
|
321
|
+
const recordingPath = (body['recording'] as { path?: string } | undefined)?.path;
|
|
316
322
|
const notes: CaptureNotes = {
|
|
317
323
|
...(body['loopRecoveryFrame'] === true ? { loopRecoveryFrame: true } : {}),
|
|
318
324
|
...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
|
|
325
|
+
...(typeof recordingPath === 'string' ? { recordingPath } : {}),
|
|
319
326
|
};
|
|
320
|
-
const caveat = describeCaptureCaveat(notes);
|
|
321
|
-
if (caveat !== null) console.warn(`vgai screenshot: ${path} — ${caveat}`);
|
|
322
327
|
return notes;
|
|
323
328
|
}
|
|
324
329
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* already documents.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { isAbsolute, resolve } from 'node:path';
|
|
23
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
24
24
|
|
|
25
25
|
/** A caller's argument, classified. */
|
|
26
26
|
export type ScreenshotArgKind = 'path' | 'label';
|
|
@@ -57,6 +57,10 @@ export interface ScreenshotTargetInput {
|
|
|
57
57
|
readonly sequence: number;
|
|
58
58
|
/** Base for resolving a relative path/artifactsDir — the process cwd. */
|
|
59
59
|
readonly cwd: string;
|
|
60
|
+
/** The project root the dev server is SERVING, when it is known. Only used
|
|
61
|
+
* to say whether the destination lands under the file watcher — see
|
|
62
|
+
* {@link ScreenshotTarget.underWatchedProjectRoot}. */
|
|
63
|
+
readonly projectRoot?: string | undefined;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export interface ScreenshotTarget {
|
|
@@ -66,6 +70,34 @@ export interface ScreenshotTarget {
|
|
|
66
70
|
/** True when the caller's own ordinal was consumed (label form only), so a
|
|
67
71
|
* path-form call never perturbs the numbering of the artifacts around it. */
|
|
68
72
|
readonly consumedSequence: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The served project root this destination falls under, when it does.
|
|
75
|
+
*
|
|
76
|
+
* The dev server watches the project root for source changes and its
|
|
77
|
+
* `server.watch.ignored` list names build output only (`dist/`, `.vercel/`,
|
|
78
|
+
* `logs/`, `.claude/worktrees/`) — nothing about capture output. So a PNG
|
|
79
|
+
* written anywhere under the root goes through the watcher on every shot,
|
|
80
|
+
* and the cost is not theoretical: measured ~3x slower frame rates while a
|
|
81
|
+
* capture loop wrote under the project. This does not CHANGE where anything
|
|
82
|
+
* is written; it lets the capture say what it costs
|
|
83
|
+
* ({@link import('./capture-notes.js').describeCaptureCaveat}).
|
|
84
|
+
*/
|
|
85
|
+
readonly underWatchedProjectRoot?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Is `file` inside `root` (not merely sharing a path prefix)? */
|
|
89
|
+
function isUnder(root: string, file: string): boolean {
|
|
90
|
+
const rel = relative(resolve(root), file);
|
|
91
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The watched-root note, when the destination has one. */
|
|
95
|
+
function watchedRoot(
|
|
96
|
+
path: string,
|
|
97
|
+
projectRoot: string | undefined,
|
|
98
|
+
): { underWatchedProjectRoot: string } | Record<string, never> {
|
|
99
|
+
if (projectRoot === undefined || projectRoot === '') return {};
|
|
100
|
+
return isUnder(projectRoot, path) ? { underWatchedProjectRoot: resolve(projectRoot) } : {};
|
|
69
101
|
}
|
|
70
102
|
|
|
71
103
|
/**
|
|
@@ -77,15 +109,23 @@ export function resolveScreenshotTarget(input: ScreenshotTargetInput): Screensho
|
|
|
77
109
|
const kind = classifyScreenshotArg(input.arg);
|
|
78
110
|
if (kind === 'path') {
|
|
79
111
|
const withExtension = /\.[a-zA-Z0-9]{1,8}$/.test(input.arg) ? input.arg : `${input.arg}.png`;
|
|
112
|
+
const path = isAbsolute(withExtension) ? withExtension : resolve(input.cwd, withExtension);
|
|
80
113
|
return {
|
|
81
114
|
kind,
|
|
82
|
-
path
|
|
115
|
+
path,
|
|
83
116
|
consumedSequence: false,
|
|
117
|
+
...watchedRoot(path, input.projectRoot),
|
|
84
118
|
};
|
|
85
119
|
}
|
|
86
120
|
const fileName = `${String(input.sequence).padStart(3, '0')}-${sanitizeScreenshotLabel(input.arg)}.png`;
|
|
87
121
|
const dir = isAbsolute(input.artifactsDir)
|
|
88
122
|
? input.artifactsDir
|
|
89
123
|
: resolve(input.cwd, input.artifactsDir);
|
|
90
|
-
|
|
124
|
+
const path = resolve(dir, fileName);
|
|
125
|
+
return {
|
|
126
|
+
kind,
|
|
127
|
+
path,
|
|
128
|
+
consumedSequence: true,
|
|
129
|
+
...watchedRoot(path, input.projectRoot),
|
|
130
|
+
};
|
|
91
131
|
}
|
package/src/game.ts
CHANGED
|
@@ -66,7 +66,12 @@ export interface LiveGame extends GameClient {
|
|
|
66
66
|
* fence. `pageErrors`/`consoleErrors` are always empty — relay mode has no
|
|
67
67
|
* separate page handle to listen on.
|
|
68
68
|
*/
|
|
69
|
-
function gameClientFor(
|
|
69
|
+
function gameClientFor(
|
|
70
|
+
port: number,
|
|
71
|
+
artifactsDir?: string,
|
|
72
|
+
instance?: string,
|
|
73
|
+
projectRoot?: string,
|
|
74
|
+
): GameClient {
|
|
70
75
|
return new GameClient({
|
|
71
76
|
transport: new RelayTransport(instance === undefined ? { port } : { port, instance }),
|
|
72
77
|
pageErrors: [],
|
|
@@ -77,14 +82,19 @@ function gameClientFor(port: number, artifactsDir?: string, instance?: string):
|
|
|
77
82
|
fenceWallMs: Date.now(),
|
|
78
83
|
warmSession: false,
|
|
79
84
|
artifactsDir,
|
|
85
|
+
projectRoot,
|
|
80
86
|
});
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
/** The unaddressed `game` client — targets the sole live instance and refuses
|
|
84
90
|
* when several are mounted. Kept as a named export for callers/tests that
|
|
85
91
|
* want just the base client. */
|
|
86
|
-
export function createGameClient(
|
|
87
|
-
|
|
92
|
+
export function createGameClient(
|
|
93
|
+
port: number,
|
|
94
|
+
artifactsDir?: string,
|
|
95
|
+
projectRoot?: string,
|
|
96
|
+
): GameClient {
|
|
97
|
+
return gameClientFor(port, artifactsDir, undefined, projectRoot);
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
/** Query the editor's live instance ids over the session wire.
|
|
@@ -110,13 +120,17 @@ async function listInstanceIds(port: number): Promise<string[]> {
|
|
|
110
120
|
|
|
111
121
|
/** Build the `LiveGame` — the base `game` client plus its instance-addressing
|
|
112
122
|
* surface. */
|
|
113
|
-
export function createLiveGame(
|
|
114
|
-
|
|
123
|
+
export function createLiveGame(
|
|
124
|
+
port: number,
|
|
125
|
+
artifactsDir?: string,
|
|
126
|
+
projectRoot?: string,
|
|
127
|
+
): LiveGame {
|
|
128
|
+
const base = gameClientFor(port, artifactsDir, undefined, projectRoot);
|
|
115
129
|
// Tag each addressed handle with the mount id it drives, so a caller can pass
|
|
116
130
|
// `handle.id` to `tools.run(..., { instance })`. The id is already known here
|
|
117
131
|
// (it is what parameterizes the relay); attaching it just hands it back.
|
|
118
132
|
const instance = (id: string): AddressedGameClient =>
|
|
119
|
-
Object.assign(gameClientFor(port, artifactsDir, id), { id });
|
|
133
|
+
Object.assign(gameClientFor(port, artifactsDir, id, projectRoot), { id });
|
|
120
134
|
const instances = async (): Promise<AddressedGameClient[]> =>
|
|
121
135
|
(await listInstanceIds(port)).map(instance);
|
|
122
136
|
return Object.assign(base, { instance, instances });
|
package/src/index.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`).
|
|
@@ -125,7 +125,7 @@ export interface LiveBindings {
|
|
|
125
125
|
function bindTo(port: number, projectRoot: string): LiveBindings {
|
|
126
126
|
const client = new EditorClient({ url: `http://127.0.0.1:${port}` });
|
|
127
127
|
const editor = new LiveEditor(client);
|
|
128
|
-
const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'));
|
|
128
|
+
const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'), projectRoot);
|
|
129
129
|
const step: GameClient['page'] = (fn) => game.page(fn);
|
|
130
130
|
const page: PageStep = Object.assign(step, { reload: () => game.reloadPage() });
|
|
131
131
|
const tools = new LiveTools(client);
|