@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/editor.d.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* hand-rolls a `fetch` to `/__editor/command` itself. (WO-8 removed the one
|
|
11
11
|
* exception, `applyDiff`, which was FILE mode rather than a live wire command.)
|
|
12
12
|
*/
|
|
13
|
-
import type { ActiveDocumentCapture, AssetKind, AssetPreviewCapture, AssetPreviewOptions, AssetPreviewSource, EditorClient, EditorState, EditorView, HistoryStep, InspectedHierarchy, InspectedInspection, PresentedEditorView, ShadingMode, ViewPreset, ViewportCapture } from '@vgai/editor-sdk';
|
|
13
|
+
import type { ActiveDocumentCapture, AnimationCaptureAction, AssetKind, AssetPreviewCapture, AssetPreviewOptions, AssetPreviewSource, EditorClient, EditorState, EditorView, HistoryStep, InspectedFieldWrite, InspectedHierarchy, InspectedInspection, PresentedEditorView, ShadingMode, ViewPreset, ViewportCapture } from '@vgai/editor-sdk';
|
|
14
14
|
import { LiveEditorDocument } from './editor-document.js';
|
|
15
|
+
import { LiveGameplayRecording } from './recording.js';
|
|
15
16
|
/** `vgai show <...>`'s four sub-verbs folded into one action name — see `showPanel`. */
|
|
16
17
|
export type PanelName = 'viewport-edit' | 'viewport-play' | 'inspector' | 'console' | 'build';
|
|
17
18
|
/**
|
|
@@ -33,6 +34,9 @@ export declare class LiveEditor {
|
|
|
33
34
|
* {@link LiveEditor.captureActiveDocument}, not a fifth verb here.
|
|
34
35
|
*/
|
|
35
36
|
readonly document: LiveEditorDocument;
|
|
37
|
+
/** Clean real-time game capture: start/stop through `vgai eval`, standard
|
|
38
|
+
* WebM on disk. This is evidence capture, not an animation authoring tool. */
|
|
39
|
+
readonly recording: LiveGameplayRecording;
|
|
36
40
|
constructor(client: EditorClient);
|
|
37
41
|
/**
|
|
38
42
|
* The active authoring adapter's persistence destination — where a save would
|
|
@@ -109,23 +113,10 @@ export declare class LiveEditor {
|
|
|
109
113
|
stats(on: boolean): Promise<void>;
|
|
110
114
|
shading(mode: ShadingMode): Promise<void>;
|
|
111
115
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* from a script.
|
|
115
|
-
*
|
|
116
|
-
* Off by default every session, on purpose: it is a statement about what you
|
|
117
|
-
* are doing right now, never a property of the game. With it off, an edit
|
|
118
|
-
* lives on the running object and says so; with it on, an edit that can be
|
|
119
|
-
* honestly anchored to the line that CREATED the object is written there,
|
|
120
|
-
* and one that cannot still says so. Answers with the server's own phrase for
|
|
121
|
-
* who records the resulting diff — your version control, or a vendored
|
|
122
|
-
* game's own lock — and refuses, with the reason, where the checkbox is
|
|
123
|
-
* disabled.
|
|
116
|
+
* Drive the recorder of the active native animation document. The document
|
|
117
|
+
* decides the native destination and refuses when it owns no safe writer.
|
|
124
118
|
*/
|
|
125
|
-
|
|
126
|
-
enabled: boolean;
|
|
127
|
-
recorder: string | null;
|
|
128
|
-
}>;
|
|
119
|
+
recordAnimation(action: AnimationCaptureAction): Promise<void>;
|
|
129
120
|
/**
|
|
130
121
|
* READ the inspector, as data — the serialized inspection subject
|
|
131
122
|
* (`editor.inspect()`; design: `docs/ARCHITECTURE-CORE.md` §Editor chrome,
|
|
@@ -185,8 +176,15 @@ export declare class LiveEditor {
|
|
|
185
176
|
/**
|
|
186
177
|
* Write one editable field from `inspect()` by its stable path, through the
|
|
187
178
|
* same Inspector IO and persistence boundary the human control uses.
|
|
179
|
+
*
|
|
180
|
+
* The answer is `{ subject, write }`, and `write` is the half worth reading
|
|
181
|
+
* first: a write with no persistence route open still succeeds — it lands on
|
|
182
|
+
* the live object and journals live-only — so `write.persisted` is how you
|
|
183
|
+
* tell a saved edit from one that will not survive the session, without
|
|
184
|
+
* diffing the tree. `write.destination` is the adapter's own words for where
|
|
185
|
+
* it went ("live-only (not saved)" is a destination, never silence).
|
|
188
186
|
*/
|
|
189
|
-
setField(path: string, value: unknown): Promise<
|
|
187
|
+
setField(path: string, value: unknown): Promise<InspectedFieldWrite>;
|
|
190
188
|
/** Undo / redo one project transaction, through the session's own history
|
|
191
189
|
* queue — the same one the keyboard shortcut drives. */
|
|
192
190
|
undo(): Promise<HistoryStep>;
|
package/dist/editor.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* exception, `applyDiff`, which was FILE mode rather than a live wire command.)
|
|
12
12
|
*/
|
|
13
13
|
import { LiveEditorDocument } from './editor-document.js';
|
|
14
|
+
import { LiveGameplayRecording } from './recording.js';
|
|
14
15
|
const EXTENSION_KIND = {
|
|
15
16
|
'.glb': 'model',
|
|
16
17
|
'.gltf': 'model',
|
|
@@ -61,9 +62,13 @@ export class LiveEditor {
|
|
|
61
62
|
* {@link LiveEditor.captureActiveDocument}, not a fifth verb here.
|
|
62
63
|
*/
|
|
63
64
|
document;
|
|
65
|
+
/** Clean real-time game capture: start/stop through `vgai eval`, standard
|
|
66
|
+
* WebM on disk. This is evidence capture, not an animation authoring tool. */
|
|
67
|
+
recording;
|
|
64
68
|
constructor(client) {
|
|
65
69
|
this.#client = client;
|
|
66
70
|
this.document = new LiveEditorDocument(client);
|
|
71
|
+
this.recording = new LiveGameplayRecording(client);
|
|
67
72
|
}
|
|
68
73
|
/**
|
|
69
74
|
* The active authoring adapter's persistence destination — where a save would
|
|
@@ -216,21 +221,11 @@ export class LiveEditor {
|
|
|
216
221
|
await this.#client.setShadingMode(mode);
|
|
217
222
|
}
|
|
218
223
|
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* from a script.
|
|
222
|
-
*
|
|
223
|
-
* Off by default every session, on purpose: it is a statement about what you
|
|
224
|
-
* are doing right now, never a property of the game. With it off, an edit
|
|
225
|
-
* lives on the running object and says so; with it on, an edit that can be
|
|
226
|
-
* honestly anchored to the line that CREATED the object is written there,
|
|
227
|
-
* and one that cannot still says so. Answers with the server's own phrase for
|
|
228
|
-
* who records the resulting diff — your version control, or a vendored
|
|
229
|
-
* game's own lock — and refuses, with the reason, where the checkbox is
|
|
230
|
-
* disabled.
|
|
224
|
+
* Drive the recorder of the active native animation document. The document
|
|
225
|
+
* decides the native destination and refuses when it owns no safe writer.
|
|
231
226
|
*/
|
|
232
|
-
async
|
|
233
|
-
|
|
227
|
+
async recordAnimation(action) {
|
|
228
|
+
await this.#client.recordAnimation(action);
|
|
234
229
|
}
|
|
235
230
|
/**
|
|
236
231
|
* READ the inspector, as data — the serialized inspection subject
|
|
@@ -295,6 +290,13 @@ export class LiveEditor {
|
|
|
295
290
|
/**
|
|
296
291
|
* Write one editable field from `inspect()` by its stable path, through the
|
|
297
292
|
* same Inspector IO and persistence boundary the human control uses.
|
|
293
|
+
*
|
|
294
|
+
* The answer is `{ subject, write }`, and `write` is the half worth reading
|
|
295
|
+
* first: a write with no persistence route open still succeeds — it lands on
|
|
296
|
+
* the live object and journals live-only — so `write.persisted` is how you
|
|
297
|
+
* tell a saved edit from one that will not survive the session, without
|
|
298
|
+
* diffing the tree. `write.destination` is the adapter's own words for where
|
|
299
|
+
* it went ("live-only (not saved)" is a destination, never silence).
|
|
298
300
|
*/
|
|
299
301
|
async setField(path, value) {
|
|
300
302
|
return this.#client.setInspectionField(path, value);
|
|
@@ -76,4 +76,23 @@ export interface BridgeTransport {
|
|
|
76
76
|
* serialized — inline every value the step needs.
|
|
77
77
|
*/
|
|
78
78
|
runPageScript(src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
|
|
79
|
+
/**
|
|
80
|
+
* P20 — reload the document showing the game, resolving only once the page
|
|
81
|
+
* is BACK and taking commands again.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately not expressible as a `runPageScript` step, which is why it
|
|
84
|
+
* is on this interface at all: a step that calls `location.reload()` kills
|
|
85
|
+
* the channel its own acknowledgement would travel on, so the caller's
|
|
86
|
+
* promise resolves on a thrown/timed-out send and says nothing about
|
|
87
|
+
* whether a page came back. Each transport therefore owns its own honest
|
|
88
|
+
* completion signal — Playwright's real `page.reload()` on the page
|
|
89
|
+
* transport, a new document load plus a working command round trip on the
|
|
90
|
+
* relay.
|
|
91
|
+
*
|
|
92
|
+
* This exists because it was MISSING: P20's recovery for stale asset bytes
|
|
93
|
+
* is a full document reload, and the only way to spell it through
|
|
94
|
+
* `vgai eval` was `page(p => p.evaluate(() => location.reload()))` — which
|
|
95
|
+
* both fails outside play mode and resolves on a send, not on a reload.
|
|
96
|
+
*/
|
|
97
|
+
reloadPage(): Promise<void>;
|
|
79
98
|
}
|
|
@@ -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
|
|
@@ -22,9 +23,9 @@
|
|
|
22
23
|
/** The conditions a capture was taken under, as facts. Both fields are
|
|
23
24
|
* optional and absent means "not so": an ordinary frame carries no notes. */
|
|
24
25
|
export interface CaptureNotes {
|
|
25
|
-
/** The
|
|
26
|
-
*
|
|
27
|
-
readonly
|
|
26
|
+
/** The host loop was starved, so the frame exists only because the runtime
|
|
27
|
+
* was asked for one deterministic tick. */
|
|
28
|
+
readonly loopRecoveryFrame?: boolean;
|
|
28
29
|
/** The page's own near-blank-frame sentence, verbatim (it owns the wording;
|
|
29
30
|
* see `composite-screenshot.ts`'s `CaptureFlatness.warning`). */
|
|
30
31
|
readonly flatnessWarning?: string;
|
|
@@ -49,7 +50,7 @@ export type CaptureListener = (capture: CaptureRecord) => void | Promise<void>;
|
|
|
49
50
|
* What a reader must be told about this frame, or `null` when there is nothing
|
|
50
51
|
* to tell.
|
|
51
52
|
*
|
|
52
|
-
* Both notes can be true at once (
|
|
53
|
+
* Both notes can be true at once (an on-demand recovery tick that also came
|
|
53
54
|
* out near-blank), and both are said — a capture that is degraded twice over
|
|
54
55
|
* must not report only the first reason.
|
|
55
56
|
*/
|
|
@@ -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
|
|
@@ -19,23 +20,23 @@
|
|
|
19
20
|
* listener — so the words a human reads in the terminal and the words a run
|
|
20
21
|
* record carries beside the frame cannot drift apart.
|
|
21
22
|
*/
|
|
22
|
-
/** The
|
|
23
|
+
/** The loop-recovery sentence. Spelled once because it is said in two places
|
|
23
24
|
* (a live console warning and a persisted record) and a second copy is a
|
|
24
25
|
* second wording. */
|
|
25
|
-
const
|
|
26
|
-
'deterministic tick on demand. It is current, not stale; it
|
|
26
|
+
const LOOP_RECOVERY_FRAME_CAVEAT = 'LOOP-RECOVERY FRAME — the host loop was starved, so the runtime rendered one ' +
|
|
27
|
+
'deterministic tick on demand. It is current, not stale; it was not produced by ordinary presentation.';
|
|
27
28
|
/**
|
|
28
29
|
* What a reader must be told about this frame, or `null` when there is nothing
|
|
29
30
|
* to tell.
|
|
30
31
|
*
|
|
31
|
-
* Both notes can be true at once (
|
|
32
|
+
* Both notes can be true at once (an on-demand recovery tick that also came
|
|
32
33
|
* out near-blank), and both are said — a capture that is degraded twice over
|
|
33
34
|
* must not report only the first reason.
|
|
34
35
|
*/
|
|
35
36
|
export function describeCaptureCaveat(notes) {
|
|
36
37
|
const parts = [];
|
|
37
|
-
if (notes.
|
|
38
|
-
parts.push(
|
|
38
|
+
if (notes.loopRecoveryFrame === true)
|
|
39
|
+
parts.push(LOOP_RECOVERY_FRAME_CAVEAT);
|
|
39
40
|
if (typeof notes.flatnessWarning === 'string' && notes.flatnessWarning !== '') {
|
|
40
41
|
parts.push(notes.flatnessWarning);
|
|
41
42
|
}
|
|
@@ -39,6 +39,10 @@ export declare class PageTransport implements BridgeTransport {
|
|
|
39
39
|
* full honesty-boundary contract; `src` is unused on this leg, kept only
|
|
40
40
|
* to satisfy the shared interface). */
|
|
41
41
|
runPageScript(_src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
|
|
42
|
+
/** Playwright's own reload already waits for the new document's `load`
|
|
43
|
+
* event, which is exactly the completion signal this method's contract
|
|
44
|
+
* asks for — nothing to reconstruct on this leg. */
|
|
45
|
+
reloadPage(): Promise<void>;
|
|
42
46
|
}
|
|
43
47
|
export interface GameClientOptions {
|
|
44
48
|
/** The bridge transport — `new PageTransport(page)` for a standalone page
|
|
@@ -111,7 +115,7 @@ export declare class GameInput {
|
|
|
111
115
|
* instead of the old set → `waitSimTime`'s 150ms-interval snapshot poll
|
|
112
116
|
* loop → clear (15+ transport round trips over the editor relay for a
|
|
113
117
|
* multi-second hold). The wait uses ordinary host-loop ticks while visible.
|
|
114
|
-
* If the browser has
|
|
118
|
+
* If the browser has loop-starved that loop, the bridge drives the held
|
|
115
119
|
* action through the same game phases with deterministic ticks; this
|
|
116
120
|
* collapses transport cost without letting a background tab deadlock it.
|
|
117
121
|
*
|
|
@@ -294,6 +298,17 @@ export declare class GameClient {
|
|
|
294
298
|
* `runPageScript` doc comment for the full contract this method wraps.
|
|
295
299
|
*/
|
|
296
300
|
page<T = unknown>(step: (page: Page) => T | Promise<T>): Promise<T>;
|
|
301
|
+
/**
|
|
302
|
+
* Reload the document showing the game, resolving only once it is back and
|
|
303
|
+
* answering commands (see `bridge-transport.ts`'s `reloadPage`).
|
|
304
|
+
*
|
|
305
|
+
* Bound as `page.reload()` on `@vgai/live`'s `page` binding. It is the one
|
|
306
|
+
* recovery for bytes that changed on disk after the running document
|
|
307
|
+
* loaded: `vgai restart` remounts every root from fresh SOURCE, but
|
|
308
|
+
* module-scope loaders and the page-lifetime asset caches (Pixi `Assets`,
|
|
309
|
+
* three's loader caches) survive a remount and keep serving the old bytes.
|
|
310
|
+
*/
|
|
311
|
+
reloadPage(): Promise<void>;
|
|
297
312
|
private toSessionFailure;
|
|
298
313
|
/** run-3 friction #2 — called at the top of every bridge dispatch, before
|
|
299
314
|
* the await, so a burst of short calls (e.g. many `hold()`s) keeps
|
|
@@ -128,6 +128,12 @@ export class PageTransport {
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
+
/** Playwright's own reload already waits for the new document's `load`
|
|
132
|
+
* event, which is exactly the completion signal this method's contract
|
|
133
|
+
* asks for — nothing to reconstruct on this leg. */
|
|
134
|
+
async reloadPage() {
|
|
135
|
+
await this.page.reload();
|
|
136
|
+
}
|
|
131
137
|
}
|
|
132
138
|
/**
|
|
133
139
|
* A relay-transport call that overran its async-invoke timeout budget
|
|
@@ -181,7 +187,7 @@ export class GameInput {
|
|
|
181
187
|
* instead of the old set → `waitSimTime`'s 150ms-interval snapshot poll
|
|
182
188
|
* loop → clear (15+ transport round trips over the editor relay for a
|
|
183
189
|
* multi-second hold). The wait uses ordinary host-loop ticks while visible.
|
|
184
|
-
* If the browser has
|
|
190
|
+
* If the browser has loop-starved that loop, the bridge drives the held
|
|
185
191
|
* action through the same game phases with deterministic ticks; this
|
|
186
192
|
* collapses transport cost without letting a background tab deadlock it.
|
|
187
193
|
*
|
|
@@ -672,6 +678,19 @@ export class GameClient {
|
|
|
672
678
|
const outcome = await this.#transport.runPageScript(step.toString(), erased);
|
|
673
679
|
return this.unwrap(outcome);
|
|
674
680
|
}
|
|
681
|
+
/**
|
|
682
|
+
* Reload the document showing the game, resolving only once it is back and
|
|
683
|
+
* answering commands (see `bridge-transport.ts`'s `reloadPage`).
|
|
684
|
+
*
|
|
685
|
+
* Bound as `page.reload()` on `@vgai/live`'s `page` binding. It is the one
|
|
686
|
+
* recovery for bytes that changed on disk after the running document
|
|
687
|
+
* loaded: `vgai restart` remounts every root from fresh SOURCE, but
|
|
688
|
+
* module-scope loaders and the page-lifetime asset caches (Pixi `Assets`,
|
|
689
|
+
* three's loader caches) survive a remount and keep serving the old bytes.
|
|
690
|
+
*/
|
|
691
|
+
async reloadPage() {
|
|
692
|
+
await this.#transport.reloadPage();
|
|
693
|
+
}
|
|
675
694
|
async toSessionFailure(err) {
|
|
676
695
|
const providers = await this.providers();
|
|
677
696
|
const screenshotPath = await this.screenshot('waitfor-timeout').catch(() => null);
|
|
@@ -694,7 +713,7 @@ export class GameClient {
|
|
|
694
713
|
// as a generic "loop is stalled" no matter WHY the clock was frozen.
|
|
695
714
|
// `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
|
|
696
715
|
// before this failure fires (see this class's constructor) — if the
|
|
697
|
-
// loop is STILL
|
|
716
|
+
// loop is STILL loop-starved here, recovery couldn't reach the tab
|
|
698
717
|
// (headless run, no-op bringToFront), and the failure block must say
|
|
699
718
|
// so explicitly rather than leaving an agent to guess "stalled" for a
|
|
700
719
|
// tab that is simply backgrounded.
|
|
@@ -39,7 +39,7 @@ export interface FailureBlockContext {
|
|
|
39
39
|
/**
|
|
40
40
|
* Issue #175 — the REAL loop liveness at the moment of failure (the
|
|
41
41
|
* timed-out/failed snapshot's `time.loopLiveness`). When this is
|
|
42
|
-
* `'
|
|
42
|
+
* `'loop-starved'`, `ratioLine` below replaces its generic "if ~0x, the
|
|
43
43
|
* loop is stalled" hint with an explicit "the tab is hidden" diagnosis —
|
|
44
44
|
* `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
|
|
45
45
|
* before this failure was ever thrown (see `client.ts`/`hidden-recovery.ts`);
|
|
@@ -49,7 +49,7 @@ export interface FailureBlockContext {
|
|
|
49
49
|
* itself crashed. Optional/`undefined` when the bridge build predates
|
|
50
50
|
* `loopLiveness` — falls back to the old generic hint, same as before.
|
|
51
51
|
*/
|
|
52
|
-
loopLiveness?: 'running' | '
|
|
52
|
+
loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null | undefined;
|
|
53
53
|
}
|
|
54
54
|
export interface SessionFailureData {
|
|
55
55
|
headline: string;
|
|
@@ -79,7 +79,7 @@ export interface SessionFailureData {
|
|
|
79
79
|
* Machine-readable twin of `elapsed.line`'s hidden-tab hint, for a JSON
|
|
80
80
|
* reporter that doesn't want to parse prose to tell a frozen tab from a
|
|
81
81
|
* stalled game. `undefined`/`null` exactly mirrors the context field. */
|
|
82
|
-
loopLiveness?: 'running' | '
|
|
82
|
+
loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null | undefined;
|
|
83
83
|
}
|
|
84
84
|
export interface AssembledFailureBlock {
|
|
85
85
|
message: string;
|
|
@@ -26,14 +26,23 @@ export function annotateStateTiers(state, providers) {
|
|
|
26
26
|
function ratioLine(simElapsedSeconds, wallElapsedMs, loopLiveness) {
|
|
27
27
|
const wallElapsedS = wallElapsedMs / 1000;
|
|
28
28
|
const ratio = wallElapsedS > 0 ? simElapsedSeconds / wallElapsedS : 0;
|
|
29
|
-
// Issue #175:
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
// Issue #175: an rAF chain can make no progress while UI play state still
|
|
30
|
+
// says running — the same ~0x reading a genuinely crashed game produces.
|
|
31
|
+
// The loop's own measurement distinguishes that condition without guessing
|
|
32
|
+
// what is happening to the browser window.
|
|
33
|
+
//
|
|
34
|
+
// P21: state the READING, not a conclusion about the human's screen. This
|
|
35
|
+
// hint used to read "the EDITOR/BROWSER TAB IS HIDDEN", and that sentence
|
|
36
|
+
// reached the owner as an assertion about a tab they were looking at.
|
|
37
|
+
// `loop-starved` is what the engine's own loop reports when no recent host
|
|
38
|
+
// rAF callback was observed; repeating that is honest, narrating the window
|
|
39
|
+
// is not.
|
|
40
|
+
const hint = loopLiveness === 'loop-starved'
|
|
41
|
+
? "the loop reported liveness 'loop-starved' — no recent host rAF " +
|
|
42
|
+
'callback was observed (not, by itself, a crash or a visibility ' +
|
|
43
|
+
'verdict). Sim time still advances through game.waitSimTime/waitFor, ' +
|
|
44
|
+
'which drive deterministic ticks over the relay; `vgai status` prints ' +
|
|
45
|
+
'the visibility readings with their ages if you need to know why'
|
|
37
46
|
: 'if ~0x, the loop is stalled; if <1x under CI, the budget may just ' +
|
|
38
47
|
'be too small for SwiftShader';
|
|
39
48
|
const line = `sim-time elapsed: ${simElapsedSeconds.toFixed(2)}s over ${wallElapsedS.toFixed(1)}s wall ` +
|
|
@@ -98,4 +98,44 @@ export declare class RelayTransport implements BridgeTransport {
|
|
|
98
98
|
* `PageTransport` (which DOES call it directly) also implements.
|
|
99
99
|
*/
|
|
100
100
|
runPageScript(src: string, _step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
|
|
101
|
+
/**
|
|
102
|
+
* P20 — order the tab to reload, then wait for EVIDENCE that it came back.
|
|
103
|
+
*
|
|
104
|
+
* Two witnesses, in order, because either one alone lies in a way that
|
|
105
|
+
* matters:
|
|
106
|
+
*
|
|
107
|
+
* 1. The tab table's EPOCH count rising. A tab's epoch id is minted per
|
|
108
|
+
* page-load and carried on its heartbeat, so the server increments
|
|
109
|
+
* this when it observes a new document — not when the tab claims one.
|
|
110
|
+
* Without it, a `page-reload` that the page never acted on (a
|
|
111
|
+
* `beforeunload` blocker, a listener that had already died) would look
|
|
112
|
+
* identical to a completed reload.
|
|
113
|
+
*
|
|
114
|
+
* `lastIndexRequestAt` was the first choice here and is measurably the
|
|
115
|
+
* WRONG one: a reload served from the browser's own cache leaves it
|
|
116
|
+
* untouched (observed live 2026-08-15 — `epochCount` went 2 → 3 while
|
|
117
|
+
* the stamp did not move), so it reported "no new document" for a
|
|
118
|
+
* reload that had plainly happened.
|
|
119
|
+
* 2. A `list-instances` command completing. That is the whole relay path
|
|
120
|
+
* — server → SSE channel → the NEW document's command listener — so it
|
|
121
|
+
* is the difference between "a document loaded" and "the session can
|
|
122
|
+
* be driven again". It is the one command with no play gate and no
|
|
123
|
+
* side effects (`command-listener.ts`), and it is held-and-retried by
|
|
124
|
+
* the relay while a tab is mid-boot, so a refusal here means the page
|
|
125
|
+
* genuinely is not answering yet.
|
|
126
|
+
*
|
|
127
|
+
* Neither witness is inferred from the send. A reload that never completes
|
|
128
|
+
* REJECTS with what was and was not observed, rather than resolving into a
|
|
129
|
+
* caller's belief that the page is fresh.
|
|
130
|
+
*/
|
|
131
|
+
reloadPage(): Promise<void>;
|
|
132
|
+
/** Poll `check` every {@link RELOAD_POLL_MS} until it holds or `deadline`
|
|
133
|
+
* passes; `false` means the deadline won, and the CALLER names what that
|
|
134
|
+
* means — a shared "timed out" sentence would be exactly the kind of
|
|
135
|
+
* message that says nothing about which witness was missing. */
|
|
136
|
+
private pollUntil;
|
|
137
|
+
/** Page-loads per present tab, from `/__editor/state`'s tab table — the
|
|
138
|
+
* server's own count of the documents it has seen a tab run. Empty when
|
|
139
|
+
* the server cannot answer, which witnesses nothing. */
|
|
140
|
+
private readTabEpochs;
|
|
101
141
|
}
|
|
@@ -34,6 +34,16 @@ const PAGE_SCRIPT_TIMEOUT_MS = 60_000;
|
|
|
34
34
|
* long enough that the per-leg preflight doesn't double a bot's request
|
|
35
35
|
* count. */
|
|
36
36
|
const HIDDEN_SAMPLE_TTL_MS = 500;
|
|
37
|
+
/** How long `reloadPage` waits for a new document AND a working command
|
|
38
|
+
* round trip before it reports what it did and did not observe. A cold Vite
|
|
39
|
+
* re-optimize on a first reload is the slow case this budget is sized for. */
|
|
40
|
+
const RELOAD_READY_TIMEOUT_MS = 60_000;
|
|
41
|
+
const RELOAD_POLL_MS = 250;
|
|
42
|
+
/** Seconds since `since`, one decimal — every `reloadPage` failure names how
|
|
43
|
+
* long it actually waited rather than quoting the budget. */
|
|
44
|
+
function elapsedSeconds(since) {
|
|
45
|
+
return ((Date.now() - since) / 1000).toFixed(1);
|
|
46
|
+
}
|
|
37
47
|
export class RelayTransport {
|
|
38
48
|
baseUrl;
|
|
39
49
|
timeoutMs;
|
|
@@ -119,9 +129,13 @@ export class RelayTransport {
|
|
|
119
129
|
const hidden = this.forceHiddenDrive || (await this.isHiddenCached());
|
|
120
130
|
if (hidden && !this.hiddenDriveAnnounced) {
|
|
121
131
|
this.hiddenDriveAnnounced = true;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
132
|
+
// P21: what was measured, and what this run is doing about it — not a
|
|
133
|
+
// claim about where the tab is. The reading is a `presence` snapshot
|
|
134
|
+
// (`isHidden` below), which ages between the tab's own reports.
|
|
135
|
+
process.stdout.write('vgai: the editor page last REPORTED document.visibilityState "hidden" — the engine ' +
|
|
136
|
+
'stops its loop while the page reports itself hidden, so this run drives ' +
|
|
137
|
+
'deterministic runTicks through the session relay instead of wall clock. Sim time ' +
|
|
138
|
+
'advances either way; `vgai status` prints that reading with its age.\n');
|
|
125
139
|
}
|
|
126
140
|
return hidden;
|
|
127
141
|
}
|
|
@@ -182,12 +196,12 @@ export class RelayTransport {
|
|
|
182
196
|
}
|
|
183
197
|
async screenshot(path) {
|
|
184
198
|
// Same preflight as every other leg — a hidden tab's canvas is a provably
|
|
185
|
-
// stale frame, and `
|
|
199
|
+
// stale frame, and `refreshStarvedFrame` is what asks the relay for a
|
|
186
200
|
// deterministic one-tick refresh instead of a `BRIDGE_SCREENSHOT_STALE`
|
|
187
201
|
// refusal (`command-listener.ts`'s `handleBridgeScreenshot`).
|
|
188
202
|
const body = await this.postCommand({
|
|
189
203
|
type: 'bridge-screenshot',
|
|
190
|
-
|
|
204
|
+
refreshStarvedFrame: await this.preflightHidden(),
|
|
191
205
|
// Address THIS transport's instance so a per-seat `game.instance(id)`
|
|
192
206
|
// screenshot captures that seat's game stack, not always the primary.
|
|
193
207
|
...(this.instance !== undefined ? { instance: this.instance } : {}),
|
|
@@ -205,7 +219,7 @@ export class RelayTransport {
|
|
|
205
219
|
// human watching this terminal is looking.
|
|
206
220
|
const warning = body['flatness']?.warning;
|
|
207
221
|
const notes = {
|
|
208
|
-
...(body['
|
|
222
|
+
...(body['loopRecoveryFrame'] === true ? { loopRecoveryFrame: true } : {}),
|
|
209
223
|
...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
|
|
210
224
|
};
|
|
211
225
|
const caveat = describeCaptureCaveat(notes);
|
|
@@ -238,4 +252,102 @@ export class RelayTransport {
|
|
|
238
252
|
};
|
|
239
253
|
}
|
|
240
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* P20 — order the tab to reload, then wait for EVIDENCE that it came back.
|
|
257
|
+
*
|
|
258
|
+
* Two witnesses, in order, because either one alone lies in a way that
|
|
259
|
+
* matters:
|
|
260
|
+
*
|
|
261
|
+
* 1. The tab table's EPOCH count rising. A tab's epoch id is minted per
|
|
262
|
+
* page-load and carried on its heartbeat, so the server increments
|
|
263
|
+
* this when it observes a new document — not when the tab claims one.
|
|
264
|
+
* Without it, a `page-reload` that the page never acted on (a
|
|
265
|
+
* `beforeunload` blocker, a listener that had already died) would look
|
|
266
|
+
* identical to a completed reload.
|
|
267
|
+
*
|
|
268
|
+
* `lastIndexRequestAt` was the first choice here and is measurably the
|
|
269
|
+
* WRONG one: a reload served from the browser's own cache leaves it
|
|
270
|
+
* untouched (observed live 2026-08-15 — `epochCount` went 2 → 3 while
|
|
271
|
+
* the stamp did not move), so it reported "no new document" for a
|
|
272
|
+
* reload that had plainly happened.
|
|
273
|
+
* 2. A `list-instances` command completing. That is the whole relay path
|
|
274
|
+
* — server → SSE channel → the NEW document's command listener — so it
|
|
275
|
+
* is the difference between "a document loaded" and "the session can
|
|
276
|
+
* be driven again". It is the one command with no play gate and no
|
|
277
|
+
* side effects (`command-listener.ts`), and it is held-and-retried by
|
|
278
|
+
* the relay while a tab is mid-boot, so a refusal here means the page
|
|
279
|
+
* genuinely is not answering yet.
|
|
280
|
+
*
|
|
281
|
+
* Neither witness is inferred from the send. A reload that never completes
|
|
282
|
+
* REJECTS with what was and was not observed, rather than resolving into a
|
|
283
|
+
* caller's belief that the page is fresh.
|
|
284
|
+
*/
|
|
285
|
+
async reloadPage() {
|
|
286
|
+
const before = await this.readTabEpochs();
|
|
287
|
+
const orderedAt = Date.now();
|
|
288
|
+
const ordered = await this.postCommand({ type: 'page-reload' }, this.timeoutMs);
|
|
289
|
+
if (!ordered.ok) {
|
|
290
|
+
throw new Error(`vgai: the editor session refused the reload — ${ordered.error ?? 'no reason given'}`);
|
|
291
|
+
}
|
|
292
|
+
// The stale visibility sample belongs to a document that no longer
|
|
293
|
+
// exists; the next leg must measure the new one.
|
|
294
|
+
this.hiddenCache = null;
|
|
295
|
+
const deadline = orderedAt + RELOAD_READY_TIMEOUT_MS;
|
|
296
|
+
const loaded = await this.pollUntil(deadline, async () => {
|
|
297
|
+
const now = await this.readTabEpochs();
|
|
298
|
+
// Any present tab whose epoch count has RISEN, or a tab that was not
|
|
299
|
+
// in the table before (the reload arrived as a fresh row). An unknown
|
|
300
|
+
// table — an unreachable server, an older one — never witnesses a
|
|
301
|
+
// reload by default; `undefined` compares false here on purpose.
|
|
302
|
+
return [...now].some(([tabId, epochs]) => epochs > (before.get(tabId) ?? 0));
|
|
303
|
+
});
|
|
304
|
+
if (!loaded) {
|
|
305
|
+
throw new Error(`vgai: reload ordered ${elapsedSeconds(orderedAt)}s ago and no tab of the session at ` +
|
|
306
|
+
`${this.baseUrl} has reported a new page load. The tab may be gone; ` +
|
|
307
|
+
'`vgai edit` reopens it.');
|
|
308
|
+
}
|
|
309
|
+
const answering = await this.pollUntil(deadline, async () => {
|
|
310
|
+
const ready = await this.postCommand({ type: 'list-instances' }, this.timeoutMs).catch(() => ({ ok: false }));
|
|
311
|
+
return ready.ok;
|
|
312
|
+
});
|
|
313
|
+
if (!answering) {
|
|
314
|
+
throw new Error(`vgai: reload ordered ${elapsedSeconds(orderedAt)}s ago — a new document loaded, but ` +
|
|
315
|
+
'its command listener has not answered yet. Check `vgai status` for page errors ' +
|
|
316
|
+
'from that load.');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Poll `check` every {@link RELOAD_POLL_MS} until it holds or `deadline`
|
|
320
|
+
* passes; `false` means the deadline won, and the CALLER names what that
|
|
321
|
+
* means — a shared "timed out" sentence would be exactly the kind of
|
|
322
|
+
* message that says nothing about which witness was missing. */
|
|
323
|
+
async pollUntil(deadline, check) {
|
|
324
|
+
for (;;) {
|
|
325
|
+
await new Promise((resolve) => setTimeout(resolve, RELOAD_POLL_MS));
|
|
326
|
+
if (await check())
|
|
327
|
+
return true;
|
|
328
|
+
if (Date.now() >= deadline)
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Page-loads per present tab, from `/__editor/state`'s tab table — the
|
|
333
|
+
* server's own count of the documents it has seen a tab run. Empty when
|
|
334
|
+
* the server cannot answer, which witnesses nothing. */
|
|
335
|
+
async readTabEpochs() {
|
|
336
|
+
try {
|
|
337
|
+
const res = await fetch(`${this.baseUrl}/__editor/state`, {
|
|
338
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
339
|
+
});
|
|
340
|
+
const state = (await res.json());
|
|
341
|
+
const epochs = new Map();
|
|
342
|
+
for (const tab of state.tabs ?? []) {
|
|
343
|
+
if (typeof tab.tabId8 === 'string' && typeof tab.epochCount === 'number') {
|
|
344
|
+
epochs.set(tab.tabId8, tab.epochCount);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return epochs;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return new Map();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
241
353
|
}
|
|
@@ -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[];
|
|
@@ -120,7 +120,7 @@ export interface VgaiBridgeHandle {
|
|
|
120
120
|
runTicks(n: number, opts?: RunTicksOptions): void;
|
|
121
121
|
/** Collapses `input.setVirtualAction(action, true)` → wait `simSeconds` of
|
|
122
122
|
* sim time (host-loop ticks while visible, deterministic same-phase ticks
|
|
123
|
-
* while
|
|
123
|
+
* while loop-starved) → `input.clearVirtualActions()` into one call — see
|
|
124
124
|
* `runtime/debug-bridge.ts`'s
|
|
125
125
|
* `holdFor` doc comment for the full contract (gated-immediately /
|
|
126
126
|
* play-stopped-mid-wait shapes). */
|