@vgai/live 0.5.13 → 0.5.14
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.d.ts +16 -18
- package/dist/editor.js +16 -14
- package/dist/game-client/bridge-transport.d.ts +19 -0
- package/dist/game-client/capture-notes.d.ts +8 -7
- package/dist/game-client/capture-notes.js +10 -9
- package/dist/game-client/client.d.ts +16 -1
- package/dist/game-client/client.js +21 -2
- package/dist/game-client/failure-block.d.ts +3 -3
- package/dist/game-client/failure-block.js +17 -8
- package/dist/game-client/relay-transport.d.ts +40 -0
- package/dist/game-client/relay-transport.js +118 -6
- package/dist/game-client/types.d.ts +6 -6
- package/dist/index.d.ts +19 -3
- package/dist/index.js +3 -1
- package/dist/recording.d.ts +28 -0
- package/dist/recording.js +52 -0
- package/package.json +3 -3
- package/src/editor.ts +20 -15
- package/src/game-client/bridge-transport.ts +19 -0
- package/src/game-client/capture-notes.ts +13 -12
- package/src/game-client/client.ts +23 -2
- package/src/game-client/failure-block.ts +21 -12
- package/src/game-client/relay-transport.ts +130 -6
- package/src/game-client/types.ts +6 -6
- package/src/index.ts +19 -4
- package/src/recording.ts +70 -0
package/dist/index.d.ts
CHANGED
|
@@ -55,13 +55,27 @@ export type { DocumentGestureOptions, DocumentKeyOptions, DocumentPasteOptions,
|
|
|
55
55
|
export { LiveEditorDocument } from './editor-document.js';
|
|
56
56
|
export { createGameClient, createLiveGame, type LiveGame } from './game.js';
|
|
57
57
|
export * from './game-client/index.js';
|
|
58
|
+
export type { GameplayRecordingResult } from './recording.js';
|
|
59
|
+
export { LiveGameplayRecording } from './recording.js';
|
|
58
60
|
export type { ProjectSessionHint, ResolvedSession, SessionListingTransport, SessionResolutionDeps, } from './session.js';
|
|
59
61
|
export { findProjectRootFrom, resolveSession } from './session.js';
|
|
60
62
|
export type { LazySession } from './singleton.js';
|
|
61
63
|
export { createLazySession } from './singleton.js';
|
|
62
64
|
export { LiveTools } from './tools.js';
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The `page` binding: a `game.page(step)`-shaped CALL (see this module's doc
|
|
67
|
+
* comment for the closure-capture limitation) that also carries the one page
|
|
68
|
+
* verb no step can express — `page.reload()`.
|
|
69
|
+
*
|
|
70
|
+
* `reload` is a property rather than a step because a step that navigates
|
|
71
|
+
* destroys the channel its own result would return on: `page(p =>
|
|
72
|
+
* p.evaluate(() => location.reload()))` was the only spelling available, and
|
|
73
|
+
* it resolves on a send while failing outright outside play mode. See
|
|
74
|
+
* `GameClient.reloadPage`.
|
|
75
|
+
*/
|
|
76
|
+
export type PageStep = GameClient['page'] & {
|
|
77
|
+
reload: GameClient['reloadPage'];
|
|
78
|
+
};
|
|
65
79
|
export interface LiveSession extends LiveBindings {
|
|
66
80
|
/** The resolved session this is bound to — useful for logging/debugging which port/project a script attached to. */
|
|
67
81
|
session: ResolvedSession;
|
|
@@ -73,7 +87,9 @@ export interface LiveBindings {
|
|
|
73
87
|
* several mounted instances (multiplayer authoring). A bare `game` call
|
|
74
88
|
* targets the sole instance and refuses when several are live. */
|
|
75
89
|
game: LiveGame;
|
|
76
|
-
/** `GameClient.page` bound to `game` — see this
|
|
90
|
+
/** `GameClient.page` bound to `game`, plus `page.reload()` — see this
|
|
91
|
+
* module's own doc comment for the wire limitation, and {@link PageStep}
|
|
92
|
+
* for why reload is a property rather than a step. */
|
|
77
93
|
page: PageStep;
|
|
78
94
|
/** Registered project callables: enumerate, inspect, and invoke. */
|
|
79
95
|
tools: LiveTools;
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,7 @@ export { inferAssetKind, LiveEditor } from './editor.js';
|
|
|
55
55
|
export { LiveEditorDocument } from './editor-document.js';
|
|
56
56
|
export { createGameClient, createLiveGame } from './game.js';
|
|
57
57
|
export * from './game-client/index.js';
|
|
58
|
+
export { LiveGameplayRecording } from './recording.js';
|
|
58
59
|
export { findProjectRootFrom, resolveSession } from './session.js';
|
|
59
60
|
export { createLazySession } from './singleton.js';
|
|
60
61
|
export { LiveTools } from './tools.js';
|
|
@@ -67,7 +68,8 @@ function bindTo(port, projectRoot) {
|
|
|
67
68
|
const client = new EditorClient({ url: `http://127.0.0.1:${port}` });
|
|
68
69
|
const editor = new LiveEditor(client);
|
|
69
70
|
const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'));
|
|
70
|
-
const
|
|
71
|
+
const step = (fn) => game.page(fn);
|
|
72
|
+
const page = Object.assign(step, { reload: () => game.reloadPage() });
|
|
71
73
|
const tools = new LiveTools(client);
|
|
72
74
|
return { editor, game, page, tools };
|
|
73
75
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `editor.recording` — real-time gameplay evidence through the existing live
|
|
3
|
+
* editor session. The browser owns MediaRecorder and the game pixels/audio;
|
|
4
|
+
* the project server streams the standard WebM to disk while this namespace
|
|
5
|
+
* exposes the start/stop controls and optional final copy destination.
|
|
6
|
+
*
|
|
7
|
+
* Recording deliberately is not a CLI workflow of its own. Agents start and
|
|
8
|
+
* stop it through the one general door (`vgai eval`) and use ordinary ffmpeg,
|
|
9
|
+
* ffprobe, and filesystem tools to review or trim the result.
|
|
10
|
+
*/
|
|
11
|
+
import type { EditorClient, GameplayRecordingOptions, GameplayRecordingStarted } from '@vgai/editor-sdk';
|
|
12
|
+
export interface GameplayRecordingResult extends GameplayRecordingStarted {
|
|
13
|
+
/** Absolute path to the standard WebM written on stop. */
|
|
14
|
+
path: string;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
droppedFrames: number;
|
|
17
|
+
frameErrors: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class LiveGameplayRecording {
|
|
20
|
+
#private;
|
|
21
|
+
constructor(client: EditorClient);
|
|
22
|
+
/** Start one clean game recording. The page owns it until `stop()`, even
|
|
23
|
+
* when start and stop are issued by separate `vgai eval` processes. */
|
|
24
|
+
start(options?: GameplayRecordingOptions): Promise<GameplayRecordingStarted>;
|
|
25
|
+
/** Finalize the active recording, write it to disk, and return honest media
|
|
26
|
+
* metadata without echoing the large base64 transport payload to stdout. */
|
|
27
|
+
stop(destination?: string): Promise<GameplayRecordingResult>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `editor.recording` — real-time gameplay evidence through the existing live
|
|
3
|
+
* editor session. The browser owns MediaRecorder and the game pixels/audio;
|
|
4
|
+
* the project server streams the standard WebM to disk while this namespace
|
|
5
|
+
* exposes the start/stop controls and optional final copy destination.
|
|
6
|
+
*
|
|
7
|
+
* Recording deliberately is not a CLI workflow of its own. Agents start and
|
|
8
|
+
* stop it through the one general door (`vgai eval`) and use ordinary ffmpeg,
|
|
9
|
+
* ffprobe, and filesystem tools to review or trim the result.
|
|
10
|
+
*/
|
|
11
|
+
import { copyFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { dirname, extname, isAbsolute, resolve } from 'node:path';
|
|
13
|
+
function recordingPath(destination) {
|
|
14
|
+
if (destination.trim() === '')
|
|
15
|
+
throw new Error('recording destination must not be empty');
|
|
16
|
+
const withExtension = extname(destination) === '' ? `${destination}.webm` : destination;
|
|
17
|
+
return isAbsolute(withExtension) ? withExtension : resolve(process.cwd(), withExtension);
|
|
18
|
+
}
|
|
19
|
+
export class LiveGameplayRecording {
|
|
20
|
+
#client;
|
|
21
|
+
constructor(client) {
|
|
22
|
+
this.#client = client;
|
|
23
|
+
}
|
|
24
|
+
/** Start one clean game recording. The page owns it until `stop()`, even
|
|
25
|
+
* when start and stop are issued by separate `vgai eval` processes. */
|
|
26
|
+
async start(options = {}) {
|
|
27
|
+
return this.#client.startGameplayRecording(options);
|
|
28
|
+
}
|
|
29
|
+
/** Finalize the active recording, write it to disk, and return honest media
|
|
30
|
+
* metadata without echoing the large base64 transport payload to stdout. */
|
|
31
|
+
async stop(destination) {
|
|
32
|
+
const capture = await this.#client.stopGameplayRecording();
|
|
33
|
+
const path = destination === undefined ? capture.path : recordingPath(destination);
|
|
34
|
+
if (path !== capture.path) {
|
|
35
|
+
await mkdir(dirname(path), { recursive: true });
|
|
36
|
+
await copyFile(capture.path, path);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
path,
|
|
40
|
+
startedAt: capture.startedAt,
|
|
41
|
+
mimeType: capture.mimeType,
|
|
42
|
+
width: capture.width,
|
|
43
|
+
height: capture.height,
|
|
44
|
+
fps: capture.fps,
|
|
45
|
+
audio: capture.audio,
|
|
46
|
+
durationMs: capture.durationMs,
|
|
47
|
+
droppedFrames: capture.droppedFrames,
|
|
48
|
+
frameErrors: capture.frameErrors,
|
|
49
|
+
layers: capture.layers,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
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.14",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@vgai/editor-sdk": "0.5.
|
|
35
|
-
"@vgai/sdk": "0.5.
|
|
34
|
+
"@vgai/editor-sdk": "0.5.14",
|
|
35
|
+
"@vgai/sdk": "0.5.14"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@playwright/test": ">=1.58.2 <2"
|
package/src/editor.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type {
|
|
15
15
|
ActiveDocumentCapture,
|
|
16
|
+
AnimationCaptureAction,
|
|
16
17
|
AssetKind,
|
|
17
18
|
AssetPreviewCapture,
|
|
18
19
|
AssetPreviewOptions,
|
|
@@ -21,6 +22,7 @@ import type {
|
|
|
21
22
|
EditorState,
|
|
22
23
|
EditorView,
|
|
23
24
|
HistoryStep,
|
|
25
|
+
InspectedFieldWrite,
|
|
24
26
|
InspectedHierarchy,
|
|
25
27
|
InspectedInspection,
|
|
26
28
|
PresentedEditorView,
|
|
@@ -29,6 +31,7 @@ import type {
|
|
|
29
31
|
ViewportCapture,
|
|
30
32
|
} from '@vgai/editor-sdk';
|
|
31
33
|
import { LiveEditorDocument } from './editor-document.js';
|
|
34
|
+
import { LiveGameplayRecording } from './recording.js';
|
|
32
35
|
|
|
33
36
|
/** `vgai show <...>`'s four sub-verbs folded into one action name — see `showPanel`. */
|
|
34
37
|
export type PanelName = 'viewport-edit' | 'viewport-play' | 'inspector' | 'console' | 'build';
|
|
@@ -86,9 +89,14 @@ export class LiveEditor {
|
|
|
86
89
|
*/
|
|
87
90
|
readonly document: LiveEditorDocument;
|
|
88
91
|
|
|
92
|
+
/** Clean real-time game capture: start/stop through `vgai eval`, standard
|
|
93
|
+
* WebM on disk. This is evidence capture, not an animation authoring tool. */
|
|
94
|
+
readonly recording: LiveGameplayRecording;
|
|
95
|
+
|
|
89
96
|
constructor(client: EditorClient) {
|
|
90
97
|
this.#client = client;
|
|
91
98
|
this.document = new LiveEditorDocument(client);
|
|
99
|
+
this.recording = new LiveGameplayRecording(client);
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
/**
|
|
@@ -271,21 +279,11 @@ export class LiveEditor {
|
|
|
271
279
|
}
|
|
272
280
|
|
|
273
281
|
/**
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
* from a script.
|
|
277
|
-
*
|
|
278
|
-
* Off by default every session, on purpose: it is a statement about what you
|
|
279
|
-
* are doing right now, never a property of the game. With it off, an edit
|
|
280
|
-
* lives on the running object and says so; with it on, an edit that can be
|
|
281
|
-
* honestly anchored to the line that CREATED the object is written there,
|
|
282
|
-
* and one that cannot still says so. Answers with the server's own phrase for
|
|
283
|
-
* who records the resulting diff — your version control, or a vendored
|
|
284
|
-
* game's own lock — and refuses, with the reason, where the checkbox is
|
|
285
|
-
* disabled.
|
|
282
|
+
* Drive the recorder of the active native animation document. The document
|
|
283
|
+
* decides the native destination and refuses when it owns no safe writer.
|
|
286
284
|
*/
|
|
287
|
-
async
|
|
288
|
-
|
|
285
|
+
async recordAnimation(action: AnimationCaptureAction): Promise<void> {
|
|
286
|
+
await this.#client.recordAnimation(action);
|
|
289
287
|
}
|
|
290
288
|
|
|
291
289
|
/**
|
|
@@ -353,8 +351,15 @@ export class LiveEditor {
|
|
|
353
351
|
/**
|
|
354
352
|
* Write one editable field from `inspect()` by its stable path, through the
|
|
355
353
|
* same Inspector IO and persistence boundary the human control uses.
|
|
354
|
+
*
|
|
355
|
+
* The answer is `{ subject, write }`, and `write` is the half worth reading
|
|
356
|
+
* first: a write with no persistence route open still succeeds — it lands on
|
|
357
|
+
* the live object and journals live-only — so `write.persisted` is how you
|
|
358
|
+
* tell a saved edit from one that will not survive the session, without
|
|
359
|
+
* diffing the tree. `write.destination` is the adapter's own words for where
|
|
360
|
+
* it went ("live-only (not saved)" is a destination, never silence).
|
|
356
361
|
*/
|
|
357
|
-
async setField(path: string, value: unknown): Promise<
|
|
362
|
+
async setField(path: string, value: unknown): Promise<InspectedFieldWrite> {
|
|
358
363
|
return this.#client.setInspectionField(path, value);
|
|
359
364
|
}
|
|
360
365
|
|
|
@@ -75,4 +75,23 @@ 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
|
+
* P20 — reload the document showing the game, resolving only once the page
|
|
80
|
+
* is BACK and taking commands again.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately not expressible as a `runPageScript` step, which is why it
|
|
83
|
+
* is on this interface at all: a step that calls `location.reload()` kills
|
|
84
|
+
* the channel its own acknowledgement would travel on, so the caller's
|
|
85
|
+
* promise resolves on a thrown/timed-out send and says nothing about
|
|
86
|
+
* whether a page came back. Each transport therefore owns its own honest
|
|
87
|
+
* completion signal — Playwright's real `page.reload()` on the page
|
|
88
|
+
* transport, a new document load plus a working command round trip on the
|
|
89
|
+
* relay.
|
|
90
|
+
*
|
|
91
|
+
* This exists because it was MISSING: P20's recovery for stale asset bytes
|
|
92
|
+
* is a full document reload, and the only way to spell it through
|
|
93
|
+
* `vgai eval` was `page(p => p.evaluate(() => location.reload()))` — which
|
|
94
|
+
* both fails outside play mode and resolves on a send, not on a reload.
|
|
95
|
+
*/
|
|
96
|
+
reloadPage(): Promise<void>;
|
|
78
97
|
}
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A PNG is silent about the conditions it was taken under. Two of those
|
|
6
6
|
* conditions change what the frame is worth as evidence, and both are already
|
|
7
|
-
* measured elsewhere in the stack: the editor page reports
|
|
8
|
-
* the
|
|
9
|
-
* on demand (`command-listener.ts`'s
|
|
7
|
+
* measured elsewhere in the stack: the editor page reports
|
|
8
|
+
* `loopRecoveryFrame` when the host loop was starved and the runtime had to
|
|
9
|
+
* render one deterministic tick on demand (`command-listener.ts`'s
|
|
10
|
+
* `handleBridgeScreenshot`), and it reports
|
|
10
11
|
* a `flatness.warning` sentence when the frame is nine-tenths one flat surface
|
|
11
12
|
* (`composite-screenshot.ts`'s `measureFlatness`). Until now both stopped at a
|
|
12
13
|
* `console.warn` inside the relay transport — visible to a human watching a
|
|
@@ -23,9 +24,9 @@
|
|
|
23
24
|
/** The conditions a capture was taken under, as facts. Both fields are
|
|
24
25
|
* optional and absent means "not so": an ordinary frame carries no notes. */
|
|
25
26
|
export interface CaptureNotes {
|
|
26
|
-
/** The
|
|
27
|
-
*
|
|
28
|
-
readonly
|
|
27
|
+
/** The host loop was starved, so the frame exists only because the runtime
|
|
28
|
+
* was asked for one deterministic tick. */
|
|
29
|
+
readonly loopRecoveryFrame?: boolean;
|
|
29
30
|
/** The page's own near-blank-frame sentence, verbatim (it owns the wording;
|
|
30
31
|
* see `composite-screenshot.ts`'s `CaptureFlatness.warning`). */
|
|
31
32
|
readonly flatnessWarning?: string;
|
|
@@ -49,24 +50,24 @@ export interface CaptureRecord {
|
|
|
49
50
|
* capture with where the run was standing, say) can. */
|
|
50
51
|
export type CaptureListener = (capture: CaptureRecord) => void | Promise<void>;
|
|
51
52
|
|
|
52
|
-
/** The
|
|
53
|
+
/** The loop-recovery sentence. Spelled once because it is said in two places
|
|
53
54
|
* (a live console warning and a persisted record) and a second copy is a
|
|
54
55
|
* second wording. */
|
|
55
|
-
const
|
|
56
|
-
'
|
|
57
|
-
'deterministic tick on demand. It is current, not stale; it
|
|
56
|
+
const LOOP_RECOVERY_FRAME_CAVEAT =
|
|
57
|
+
'LOOP-RECOVERY FRAME — the host loop was starved, so the runtime rendered one ' +
|
|
58
|
+
'deterministic tick on demand. It is current, not stale; it was not produced by ordinary presentation.';
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* What a reader must be told about this frame, or `null` when there is nothing
|
|
61
62
|
* to tell.
|
|
62
63
|
*
|
|
63
|
-
* Both notes can be true at once (
|
|
64
|
+
* Both notes can be true at once (an on-demand recovery tick that also came
|
|
64
65
|
* out near-blank), and both are said — a capture that is degraded twice over
|
|
65
66
|
* must not report only the first reason.
|
|
66
67
|
*/
|
|
67
68
|
export function describeCaptureCaveat(notes: CaptureNotes): string | null {
|
|
68
69
|
const parts: string[] = [];
|
|
69
|
-
if (notes.
|
|
70
|
+
if (notes.loopRecoveryFrame === true) parts.push(LOOP_RECOVERY_FRAME_CAVEAT);
|
|
70
71
|
if (typeof notes.flatnessWarning === 'string' && notes.flatnessWarning !== '') {
|
|
71
72
|
parts.push(notes.flatnessWarning);
|
|
72
73
|
}
|
|
@@ -157,6 +157,13 @@ export class PageTransport implements BridgeTransport {
|
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
+
|
|
161
|
+
/** Playwright's own reload already waits for the new document's `load`
|
|
162
|
+
* event, which is exactly the completion signal this method's contract
|
|
163
|
+
* asks for — nothing to reconstruct on this leg. */
|
|
164
|
+
async reloadPage(): Promise<void> {
|
|
165
|
+
await this.page.reload();
|
|
166
|
+
}
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
export interface GameClientOptions {
|
|
@@ -268,7 +275,7 @@ export class GameInput {
|
|
|
268
275
|
* instead of the old set → `waitSimTime`'s 150ms-interval snapshot poll
|
|
269
276
|
* loop → clear (15+ transport round trips over the editor relay for a
|
|
270
277
|
* multi-second hold). The wait uses ordinary host-loop ticks while visible.
|
|
271
|
-
* If the browser has
|
|
278
|
+
* If the browser has loop-starved that loop, the bridge drives the held
|
|
272
279
|
* action through the same game phases with deterministic ticks; this
|
|
273
280
|
* collapses transport cost without letting a background tab deadlock it.
|
|
274
281
|
*
|
|
@@ -797,6 +804,20 @@ export class GameClient {
|
|
|
797
804
|
return this.unwrap<T>(outcome);
|
|
798
805
|
}
|
|
799
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Reload the document showing the game, resolving only once it is back and
|
|
809
|
+
* answering commands (see `bridge-transport.ts`'s `reloadPage`).
|
|
810
|
+
*
|
|
811
|
+
* Bound as `page.reload()` on `@vgai/live`'s `page` binding. It is the one
|
|
812
|
+
* recovery for bytes that changed on disk after the running document
|
|
813
|
+
* loaded: `vgai restart` remounts every root from fresh SOURCE, but
|
|
814
|
+
* module-scope loaders and the page-lifetime asset caches (Pixi `Assets`,
|
|
815
|
+
* three's loader caches) survive a remount and keep serving the old bytes.
|
|
816
|
+
*/
|
|
817
|
+
async reloadPage(): Promise<void> {
|
|
818
|
+
await this.#transport.reloadPage();
|
|
819
|
+
}
|
|
820
|
+
|
|
800
821
|
private async toSessionFailure(err: WaitForTimeoutError): Promise<SessionFailure> {
|
|
801
822
|
const providers = await this.providers();
|
|
802
823
|
const screenshotPath = await this.screenshot('waitfor-timeout').catch(() => null);
|
|
@@ -820,7 +841,7 @@ export class GameClient {
|
|
|
820
841
|
// as a generic "loop is stalled" no matter WHY the clock was frozen.
|
|
821
842
|
// `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
|
|
822
843
|
// before this failure fires (see this class's constructor) — if the
|
|
823
|
-
// loop is STILL
|
|
844
|
+
// loop is STILL loop-starved here, recovery couldn't reach the tab
|
|
824
845
|
// (headless run, no-op bringToFront), and the failure block must say
|
|
825
846
|
// so explicitly rather than leaving an agent to guess "stalled" for a
|
|
826
847
|
// tab that is simply backgrounded.
|
|
@@ -42,7 +42,7 @@ export interface FailureBlockContext {
|
|
|
42
42
|
/**
|
|
43
43
|
* Issue #175 — the REAL loop liveness at the moment of failure (the
|
|
44
44
|
* timed-out/failed snapshot's `time.loopLiveness`). When this is
|
|
45
|
-
* `'
|
|
45
|
+
* `'loop-starved'`, `ratioLine` below replaces its generic "if ~0x, the
|
|
46
46
|
* loop is stalled" hint with an explicit "the tab is hidden" diagnosis —
|
|
47
47
|
* `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
|
|
48
48
|
* before this failure was ever thrown (see `client.ts`/`hidden-recovery.ts`);
|
|
@@ -52,7 +52,7 @@ export interface FailureBlockContext {
|
|
|
52
52
|
* itself crashed. Optional/`undefined` when the bridge build predates
|
|
53
53
|
* `loopLiveness` — falls back to the old generic hint, same as before.
|
|
54
54
|
*/
|
|
55
|
-
loopLiveness?: 'running' | '
|
|
55
|
+
loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null | undefined;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export interface SessionFailureData {
|
|
@@ -75,7 +75,7 @@ export interface SessionFailureData {
|
|
|
75
75
|
* Machine-readable twin of `elapsed.line`'s hidden-tab hint, for a JSON
|
|
76
76
|
* reporter that doesn't want to parse prose to tell a frozen tab from a
|
|
77
77
|
* stalled game. `undefined`/`null` exactly mirrors the context field. */
|
|
78
|
-
loopLiveness?: 'running' | '
|
|
78
|
+
loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null | undefined;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export interface AssembledFailureBlock {
|
|
@@ -103,19 +103,28 @@ export function annotateStateTiers(
|
|
|
103
103
|
function ratioLine(
|
|
104
104
|
simElapsedSeconds: number,
|
|
105
105
|
wallElapsedMs: number,
|
|
106
|
-
loopLiveness?: 'running' | '
|
|
106
|
+
loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null,
|
|
107
107
|
): { ratio: number; line: string } {
|
|
108
108
|
const wallElapsedS = wallElapsedMs / 1000;
|
|
109
109
|
const ratio = wallElapsedS > 0 ? simElapsedSeconds / wallElapsedS : 0;
|
|
110
|
-
// Issue #175:
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
110
|
+
// Issue #175: an rAF chain can make no progress while UI play state still
|
|
111
|
+
// says running — the same ~0x reading a genuinely crashed game produces.
|
|
112
|
+
// The loop's own measurement distinguishes that condition without guessing
|
|
113
|
+
// what is happening to the browser window.
|
|
114
|
+
//
|
|
115
|
+
// P21: state the READING, not a conclusion about the human's screen. This
|
|
116
|
+
// hint used to read "the EDITOR/BROWSER TAB IS HIDDEN", and that sentence
|
|
117
|
+
// reached the owner as an assertion about a tab they were looking at.
|
|
118
|
+
// `loop-starved` is what the engine's own loop reports when no recent host
|
|
119
|
+
// rAF callback was observed; repeating that is honest, narrating the window
|
|
120
|
+
// is not.
|
|
114
121
|
const hint =
|
|
115
|
-
loopLiveness === '
|
|
116
|
-
?
|
|
117
|
-
'
|
|
118
|
-
'
|
|
122
|
+
loopLiveness === 'loop-starved'
|
|
123
|
+
? "the loop reported liveness 'loop-starved' — no recent host rAF " +
|
|
124
|
+
'callback was observed (not, by itself, a crash or a visibility ' +
|
|
125
|
+
'verdict). Sim time still advances through game.waitSimTime/waitFor, ' +
|
|
126
|
+
'which drive deterministic ticks over the relay; `vgai status` prints ' +
|
|
127
|
+
'the visibility readings with their ages if you need to know why'
|
|
119
128
|
: 'if ~0x, the loop is stalled; if <1x under CI, the budget may just ' +
|
|
120
129
|
'be too small for SwiftShader';
|
|
121
130
|
const line =
|
|
@@ -75,6 +75,18 @@ const PAGE_SCRIPT_TIMEOUT_MS = 60_000;
|
|
|
75
75
|
* count. */
|
|
76
76
|
const HIDDEN_SAMPLE_TTL_MS = 500;
|
|
77
77
|
|
|
78
|
+
/** How long `reloadPage` waits for a new document AND a working command
|
|
79
|
+
* round trip before it reports what it did and did not observe. A cold Vite
|
|
80
|
+
* re-optimize on a first reload is the slow case this budget is sized for. */
|
|
81
|
+
const RELOAD_READY_TIMEOUT_MS = 60_000;
|
|
82
|
+
const RELOAD_POLL_MS = 250;
|
|
83
|
+
|
|
84
|
+
/** Seconds since `since`, one decimal — every `reloadPage` failure names how
|
|
85
|
+
* long it actually waited rather than quoting the budget. */
|
|
86
|
+
function elapsedSeconds(since: number): string {
|
|
87
|
+
return ((Date.now() - since) / 1000).toFixed(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
export class RelayTransport implements BridgeTransport {
|
|
79
91
|
private readonly baseUrl: string;
|
|
80
92
|
private readonly timeoutMs: number;
|
|
@@ -174,10 +186,14 @@ export class RelayTransport implements BridgeTransport {
|
|
|
174
186
|
const hidden = this.forceHiddenDrive || (await this.isHiddenCached());
|
|
175
187
|
if (hidden && !this.hiddenDriveAnnounced) {
|
|
176
188
|
this.hiddenDriveAnnounced = true;
|
|
189
|
+
// P21: what was measured, and what this run is doing about it — not a
|
|
190
|
+
// claim about where the tab is. The reading is a `presence` snapshot
|
|
191
|
+
// (`isHidden` below), which ages between the tab's own reports.
|
|
177
192
|
process.stdout.write(
|
|
178
|
-
'vgai: the editor
|
|
179
|
-
'
|
|
180
|
-
'
|
|
193
|
+
'vgai: the editor page last REPORTED document.visibilityState "hidden" — the engine ' +
|
|
194
|
+
'stops its loop while the page reports itself hidden, so this run drives ' +
|
|
195
|
+
'deterministic runTicks through the session relay instead of wall clock. Sim time ' +
|
|
196
|
+
'advances either way; `vgai status` prints that reading with its age.\n',
|
|
181
197
|
);
|
|
182
198
|
}
|
|
183
199
|
return hidden;
|
|
@@ -249,13 +265,13 @@ export class RelayTransport implements BridgeTransport {
|
|
|
249
265
|
|
|
250
266
|
async screenshot(path: string): Promise<CaptureNotes> {
|
|
251
267
|
// Same preflight as every other leg — a hidden tab's canvas is a provably
|
|
252
|
-
// stale frame, and `
|
|
268
|
+
// stale frame, and `refreshStarvedFrame` is what asks the relay for a
|
|
253
269
|
// deterministic one-tick refresh instead of a `BRIDGE_SCREENSHOT_STALE`
|
|
254
270
|
// refusal (`command-listener.ts`'s `handleBridgeScreenshot`).
|
|
255
271
|
const body = await this.postCommand(
|
|
256
272
|
{
|
|
257
273
|
type: 'bridge-screenshot',
|
|
258
|
-
|
|
274
|
+
refreshStarvedFrame: await this.preflightHidden(),
|
|
259
275
|
// Address THIS transport's instance so a per-seat `game.instance(id)`
|
|
260
276
|
// screenshot captures that seat's game stack, not always the primary.
|
|
261
277
|
...(this.instance !== undefined ? { instance: this.instance } : {}),
|
|
@@ -277,7 +293,7 @@ export class RelayTransport implements BridgeTransport {
|
|
|
277
293
|
// human watching this terminal is looking.
|
|
278
294
|
const warning = (body['flatness'] as { warning?: string } | undefined)?.warning;
|
|
279
295
|
const notes: CaptureNotes = {
|
|
280
|
-
...(body['
|
|
296
|
+
...(body['loopRecoveryFrame'] === true ? { loopRecoveryFrame: true } : {}),
|
|
281
297
|
...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
|
|
282
298
|
};
|
|
283
299
|
const caveat = describeCaptureCaveat(notes);
|
|
@@ -310,4 +326,112 @@ export class RelayTransport implements BridgeTransport {
|
|
|
310
326
|
};
|
|
311
327
|
}
|
|
312
328
|
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* P20 — order the tab to reload, then wait for EVIDENCE that it came back.
|
|
332
|
+
*
|
|
333
|
+
* Two witnesses, in order, because either one alone lies in a way that
|
|
334
|
+
* matters:
|
|
335
|
+
*
|
|
336
|
+
* 1. The tab table's EPOCH count rising. A tab's epoch id is minted per
|
|
337
|
+
* page-load and carried on its heartbeat, so the server increments
|
|
338
|
+
* this when it observes a new document — not when the tab claims one.
|
|
339
|
+
* Without it, a `page-reload` that the page never acted on (a
|
|
340
|
+
* `beforeunload` blocker, a listener that had already died) would look
|
|
341
|
+
* identical to a completed reload.
|
|
342
|
+
*
|
|
343
|
+
* `lastIndexRequestAt` was the first choice here and is measurably the
|
|
344
|
+
* WRONG one: a reload served from the browser's own cache leaves it
|
|
345
|
+
* untouched (observed live 2026-08-15 — `epochCount` went 2 → 3 while
|
|
346
|
+
* the stamp did not move), so it reported "no new document" for a
|
|
347
|
+
* reload that had plainly happened.
|
|
348
|
+
* 2. A `list-instances` command completing. That is the whole relay path
|
|
349
|
+
* — server → SSE channel → the NEW document's command listener — so it
|
|
350
|
+
* is the difference between "a document loaded" and "the session can
|
|
351
|
+
* be driven again". It is the one command with no play gate and no
|
|
352
|
+
* side effects (`command-listener.ts`), and it is held-and-retried by
|
|
353
|
+
* the relay while a tab is mid-boot, so a refusal here means the page
|
|
354
|
+
* genuinely is not answering yet.
|
|
355
|
+
*
|
|
356
|
+
* Neither witness is inferred from the send. A reload that never completes
|
|
357
|
+
* REJECTS with what was and was not observed, rather than resolving into a
|
|
358
|
+
* caller's belief that the page is fresh.
|
|
359
|
+
*/
|
|
360
|
+
async reloadPage(): Promise<void> {
|
|
361
|
+
const before = await this.readTabEpochs();
|
|
362
|
+
const orderedAt = Date.now();
|
|
363
|
+
const ordered = await this.postCommand({ type: 'page-reload' }, this.timeoutMs);
|
|
364
|
+
if (!ordered.ok) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`vgai: the editor session refused the reload — ${ordered.error ?? 'no reason given'}`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
// The stale visibility sample belongs to a document that no longer
|
|
370
|
+
// exists; the next leg must measure the new one.
|
|
371
|
+
this.hiddenCache = null;
|
|
372
|
+
const deadline = orderedAt + RELOAD_READY_TIMEOUT_MS;
|
|
373
|
+
const loaded = await this.pollUntil(deadline, async () => {
|
|
374
|
+
const now = await this.readTabEpochs();
|
|
375
|
+
// Any present tab whose epoch count has RISEN, or a tab that was not
|
|
376
|
+
// in the table before (the reload arrived as a fresh row). An unknown
|
|
377
|
+
// table — an unreachable server, an older one — never witnesses a
|
|
378
|
+
// reload by default; `undefined` compares false here on purpose.
|
|
379
|
+
return [...now].some(([tabId, epochs]) => epochs > (before.get(tabId) ?? 0));
|
|
380
|
+
});
|
|
381
|
+
if (!loaded) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`vgai: reload ordered ${elapsedSeconds(orderedAt)}s ago and no tab of the session at ` +
|
|
384
|
+
`${this.baseUrl} has reported a new page load. The tab may be gone; ` +
|
|
385
|
+
'`vgai edit` reopens it.',
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
const answering = await this.pollUntil(deadline, async () => {
|
|
389
|
+
const ready = await this.postCommand({ type: 'list-instances' }, this.timeoutMs).catch(
|
|
390
|
+
() => ({ ok: false }) as RelayCommandBody,
|
|
391
|
+
);
|
|
392
|
+
return ready.ok;
|
|
393
|
+
});
|
|
394
|
+
if (!answering) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`vgai: reload ordered ${elapsedSeconds(orderedAt)}s ago — a new document loaded, but ` +
|
|
397
|
+
'its command listener has not answered yet. Check `vgai status` for page errors ' +
|
|
398
|
+
'from that load.',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Poll `check` every {@link RELOAD_POLL_MS} until it holds or `deadline`
|
|
404
|
+
* passes; `false` means the deadline won, and the CALLER names what that
|
|
405
|
+
* means — a shared "timed out" sentence would be exactly the kind of
|
|
406
|
+
* message that says nothing about which witness was missing. */
|
|
407
|
+
private async pollUntil(deadline: number, check: () => Promise<boolean>): Promise<boolean> {
|
|
408
|
+
for (;;) {
|
|
409
|
+
await new Promise((resolve) => setTimeout(resolve, RELOAD_POLL_MS));
|
|
410
|
+
if (await check()) return true;
|
|
411
|
+
if (Date.now() >= deadline) return false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Page-loads per present tab, from `/__editor/state`'s tab table — the
|
|
416
|
+
* server's own count of the documents it has seen a tab run. Empty when
|
|
417
|
+
* the server cannot answer, which witnesses nothing. */
|
|
418
|
+
private async readTabEpochs(): Promise<Map<string, number>> {
|
|
419
|
+
try {
|
|
420
|
+
const res = await fetch(`${this.baseUrl}/__editor/state`, {
|
|
421
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
422
|
+
});
|
|
423
|
+
const state = (await res.json()) as {
|
|
424
|
+
tabs?: { tabId8?: string; epochCount?: number }[];
|
|
425
|
+
};
|
|
426
|
+
const epochs = new Map<string, number>();
|
|
427
|
+
for (const tab of state.tabs ?? []) {
|
|
428
|
+
if (typeof tab.tabId8 === 'string' && typeof tab.epochCount === 'number') {
|
|
429
|
+
epochs.set(tab.tabId8, tab.epochCount);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return epochs;
|
|
433
|
+
} catch {
|
|
434
|
+
return new Map();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
313
437
|
}
|
package/src/game-client/types.ts
CHANGED
|
@@ -86,15 +86,15 @@ export interface DebugSnapshot {
|
|
|
86
86
|
* Issue #175 — the REAL engine `GameLoop.liveness` behind this session
|
|
87
87
|
* (mirrors `@vgai/engine`'s `GameLoopLiveness`; this package never
|
|
88
88
|
* imports the engine — see the module doc above — so the union is
|
|
89
|
-
* declared here from the same contract). `'
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
89
|
+
* declared here from the same contract). `'loop-starved'` means no recent
|
|
90
|
+
* host rAF callback was observed. It may reflect the current visibility
|
|
91
|
+
* gate or an armed callback the browser has starved; it is NOT itself a
|
|
92
|
+
* visibility verdict or proof that the game crashed.
|
|
93
93
|
* `undefined` against an older bridge build that predates this field;
|
|
94
94
|
* `null` when the live bridge has no loop wired at all (should not
|
|
95
95
|
* happen against a real `Game`, but never fabricated either way).
|
|
96
96
|
*/
|
|
97
|
-
loopLiveness?: 'running' | '
|
|
97
|
+
loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null;
|
|
98
98
|
};
|
|
99
99
|
state: Record<string, unknown>;
|
|
100
100
|
events: TickStampedEvent[];
|
|
@@ -121,7 +121,7 @@ export interface VgaiBridgeHandle {
|
|
|
121
121
|
runTicks(n: number, opts?: RunTicksOptions): void;
|
|
122
122
|
/** Collapses `input.setVirtualAction(action, true)` → wait `simSeconds` of
|
|
123
123
|
* sim time (host-loop ticks while visible, deterministic same-phase ticks
|
|
124
|
-
* while
|
|
124
|
+
* while loop-starved) → `input.clearVirtualActions()` into one call — see
|
|
125
125
|
* `runtime/debug-bridge.ts`'s
|
|
126
126
|
* `holdFor` doc comment for the full contract (gated-immediately /
|
|
127
127
|
* play-stopped-mid-wait shapes). */
|