@vgai/live 0.5.40 → 0.5.42
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 +2 -0
- package/dist/editor.js +4 -0
- package/dist/game-client/client.d.ts +13 -1
- package/dist/game-client/client.js +65 -28
- package/dist/game-client/fast-forward.d.ts +5 -0
- package/dist/game-client/relay-transport.js +4 -1
- package/package.json +4 -3
- package/src/editor.ts +5 -0
- package/src/game-client/client.ts +83 -17
- package/src/game-client/fast-forward.ts +5 -0
- package/src/game-client/relay-transport.ts +4 -1
package/dist/editor.d.ts
CHANGED
|
@@ -178,6 +178,8 @@ export declare class LiveEditor {
|
|
|
178
178
|
* tree would be a fabricated answer about a surface nobody is being shown.
|
|
179
179
|
*/
|
|
180
180
|
hierarchy(): Promise<InspectedHierarchy>;
|
|
181
|
+
/** Expand every branch through the Hierarchy panel's own action. */
|
|
182
|
+
expandHierarchyAll(): Promise<void>;
|
|
181
183
|
/**
|
|
182
184
|
* Write one editable field from `inspect()` by its stable path, through the
|
|
183
185
|
* same Inspector IO and persistence boundary the human control uses.
|
package/dist/editor.js
CHANGED
|
@@ -289,6 +289,10 @@ export class LiveEditor {
|
|
|
289
289
|
async hierarchy() {
|
|
290
290
|
return this.#client.hierarchy();
|
|
291
291
|
}
|
|
292
|
+
/** Expand every branch through the Hierarchy panel's own action. */
|
|
293
|
+
async expandHierarchyAll() {
|
|
294
|
+
await this.#client.expandHierarchyAll();
|
|
295
|
+
}
|
|
292
296
|
/**
|
|
293
297
|
* Write one editable field from `inspect()` by its stable path, through the
|
|
294
298
|
* same Inspector IO and persistence boundary the human control uses.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Page } from '@playwright/test';
|
|
10
10
|
import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
|
|
11
11
|
import { type CaptureListener, type CaptureNotes } from './capture-notes.js';
|
|
12
|
-
import { type FastForwardBudget, type FastForwardOptions } from './fast-forward.js';
|
|
12
|
+
import { type FastForwardBudget, type FastForwardOptions, type FastForwardTime } from './fast-forward.js';
|
|
13
13
|
import { type TpsStats } from './perf-sampling.js';
|
|
14
14
|
import type { DebugCommandInfo, DebugSnapshot, ProviderInfo, VirtualActionValue } from './types.js';
|
|
15
15
|
import { type WaitForBudget } from './wait-for.js';
|
|
@@ -226,6 +226,15 @@ export declare class GameClient {
|
|
|
226
226
|
providers(): Promise<ProviderInfo[]>;
|
|
227
227
|
commands(): Promise<DebugCommandInfo[]>;
|
|
228
228
|
snapshot(sinceSeq?: number): Promise<DebugSnapshot>;
|
|
229
|
+
/**
|
|
230
|
+
* Read only the simulation clock for a predicate-free wait.
|
|
231
|
+
*
|
|
232
|
+
* `snapshot()` intentionally batches every declared provider for `waitFor`, whose predicate may
|
|
233
|
+
* read any of them. `waitSimTime` has no predicate: serializing a large `unity` provider every
|
|
234
|
+
* 150ms can itself consume multiple frames and corrupt the performance window it is advancing.
|
|
235
|
+
* Keep the two hardening observers fed exactly as an ordinary snapshot does.
|
|
236
|
+
*/
|
|
237
|
+
private readTime;
|
|
229
238
|
/** Fixture-teardown perf read: p50/p95 effective ticks-per-second over all
|
|
230
239
|
* the snapshot polls this test performed. */
|
|
231
240
|
tpsStats(): TpsStats;
|
|
@@ -243,6 +252,9 @@ export declare class GameClient {
|
|
|
243
252
|
* AFTER the tps baseline reset below, so it never itself corrupts the tps
|
|
244
253
|
* stats either).
|
|
245
254
|
*/
|
|
255
|
+
fastForward(budget: FastForwardBudget, opts: FastForwardOptions & {
|
|
256
|
+
result: 'time';
|
|
257
|
+
}): Promise<FastForwardTime>;
|
|
246
258
|
fastForward(budget: FastForwardBudget, opts?: FastForwardOptions): Promise<DebugSnapshot>;
|
|
247
259
|
waitFor(pred: (s: (name: string) => unknown) => boolean, budget: WaitForBudget): Promise<void>;
|
|
248
260
|
/** Used by `input.hold` — waits for a sim-time delta to pass with no
|
|
@@ -77,6 +77,24 @@ async function bridgeCallInPageAsync(args) {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* A relay step crosses the wire as function source, so compiler-owned helpers that live beside the
|
|
82
|
+
* function in its Node module are closures too. esbuild's `keepNames` transform is the live case:
|
|
83
|
+
* a named helper inside an otherwise literal callback becomes `__name(fn, "helper")`, while the
|
|
84
|
+
* module-level `__name` implementation is absent after `step.toString()`. Seat that exact compiler
|
|
85
|
+
* primitive inside the serialized function instead of installing a page global or asking every
|
|
86
|
+
* caller to avoid ordinary named local helpers.
|
|
87
|
+
*
|
|
88
|
+
* Ordinary source stays byte-for-byte unchanged. That preserves the public wire account and keeps
|
|
89
|
+
* unsupported user closures loud; this only completes the source for a compiler helper whose
|
|
90
|
+
* semantics are intrinsic and deterministic.
|
|
91
|
+
*/
|
|
92
|
+
function selfContainedStepSource(step) {
|
|
93
|
+
const source = step.toString();
|
|
94
|
+
if (!/\b__name\s*\(/u.test(source))
|
|
95
|
+
return source;
|
|
96
|
+
return `(scope) => { const __name = (target, value) => Object.defineProperty(target, "name", { value, configurable: true }); return (${source})(scope); }`;
|
|
97
|
+
}
|
|
80
98
|
/**
|
|
81
99
|
* #140 — the `BridgeTransport` `page.evaluate` implementation. This is the
|
|
82
100
|
* ONE place a `Page` is ever touched to drive `window.__vgai` (module doc
|
|
@@ -504,6 +522,20 @@ export class GameClient {
|
|
|
504
522
|
await this.#hiddenRecovery.observeTick(snap.time.tick);
|
|
505
523
|
return snap;
|
|
506
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Read only the simulation clock for a predicate-free wait.
|
|
527
|
+
*
|
|
528
|
+
* `snapshot()` intentionally batches every declared provider for `waitFor`, whose predicate may
|
|
529
|
+
* read any of them. `waitSimTime` has no predicate: serializing a large `unity` provider every
|
|
530
|
+
* 150ms can itself consume multiple frames and corrupt the performance window it is advancing.
|
|
531
|
+
* Keep the two hardening observers fed exactly as an ordinary snapshot does.
|
|
532
|
+
*/
|
|
533
|
+
async readTime() {
|
|
534
|
+
const time = await this.callBridge('state', 'time');
|
|
535
|
+
this.#tps.record(time.tick, Date.now());
|
|
536
|
+
await this.#hiddenRecovery.observeTick(time.tick);
|
|
537
|
+
return time;
|
|
538
|
+
}
|
|
507
539
|
/** Fixture-teardown perf read: p50/p95 effective ticks-per-second over all
|
|
508
540
|
* the snapshot polls this test performed. */
|
|
509
541
|
tpsStats() {
|
|
@@ -513,18 +545,6 @@ export class GameClient {
|
|
|
513
545
|
hiddenRecoveryTriggered() {
|
|
514
546
|
return this.#hiddenRecovery.wasTriggered();
|
|
515
547
|
}
|
|
516
|
-
/**
|
|
517
|
-
* D15/T-D15.4 — synchronously drives `budget` worth of sim time/ticks via
|
|
518
|
-
* the live `Game`'s `runTicks` (through the debug bridge), instead of
|
|
519
|
-
* waiting for real wall-clock time to pass. Doctrine (see
|
|
520
|
-
* `fast-forward.ts`'s module doc, which also documents the two honesty
|
|
521
|
-
* decisions this wraps): SETUP/STAGING traversal — reaching a known
|
|
522
|
-
* late-game state fast — not a substitute for real-input proofs, which
|
|
523
|
-
* still run in real ticks. Returns the final `{time, state, events,
|
|
524
|
-
* pageErrors}` snapshot (a completely ordinary `snapshot()` read, taken
|
|
525
|
-
* AFTER the tps baseline reset below, so it never itself corrupts the tps
|
|
526
|
-
* stats either).
|
|
527
|
-
*/
|
|
528
548
|
async fastForward(budget, opts) {
|
|
529
549
|
// Same options-object-only contract as `waitSimTime` (`ticksForBudget`
|
|
530
550
|
// would otherwise fail on `'simTicks' in 0.5` with a raw TypeError that
|
|
@@ -556,21 +576,26 @@ export class GameClient {
|
|
|
556
576
|
}
|
|
557
577
|
},
|
|
558
578
|
readTime: async () => {
|
|
559
|
-
// Raw bridge read — deliberately NOT `this.snapshot()`,
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
579
|
+
// Raw, clock-only bridge read — deliberately NOT `this.snapshot()`,
|
|
580
|
+
// which both feeds the TpsAccumulator and serializes every declared
|
|
581
|
+
// provider. Large imported worlds can carry megabytes of census state;
|
|
582
|
+
// the batching math needs only these two numbers.
|
|
583
|
+
const time = await this.callBridge('state', 'time');
|
|
584
|
+
return { tick: time.tick, simSeconds: time.simSeconds };
|
|
563
585
|
},
|
|
564
586
|
heartbeat: (info) => {
|
|
565
587
|
console.log(`vgai fastForward: ${info.ticksDone}/${info.ticksTotal} ticks driven`);
|
|
566
588
|
},
|
|
567
589
|
};
|
|
568
|
-
await runFastForward(budget, opts ?? {}, clock);
|
|
590
|
+
const finalTime = await runFastForward(budget, opts ?? {}, clock);
|
|
569
591
|
// The burst is over — reset the baseline so the very next ordinary poll
|
|
570
592
|
// (including the `snapshot()` call right below) treats itself as a fresh
|
|
571
593
|
// "first observation" rather than diffing across the burst's enormous
|
|
572
594
|
// tick delta over a near-zero wall delta.
|
|
573
595
|
this.#tps.resetBaseline();
|
|
596
|
+
if (opts?.result === 'time') {
|
|
597
|
+
return finalTime;
|
|
598
|
+
}
|
|
574
599
|
return this.snapshot();
|
|
575
600
|
}
|
|
576
601
|
async waitFor(pred, budget) {
|
|
@@ -612,7 +637,13 @@ export class GameClient {
|
|
|
612
637
|
// fires either. Refuse in the caller's own vocabulary instead of hanging.
|
|
613
638
|
assertValidWaitForBudget(budget, 'waitSimTime');
|
|
614
639
|
const startWall = Date.now();
|
|
615
|
-
|
|
640
|
+
// Predicate-free means clock-only from the FIRST read, not merely from the
|
|
641
|
+
// second poll onward. A full initial snapshot serializes every provider;
|
|
642
|
+
// the Unity FPS provider alone carries ~4,500 objects and measured a
|
|
643
|
+
// 150-300ms main-thread hitch each time a caller began an otherwise cheap
|
|
644
|
+
// wait. Failure diagnostics still take one complete terminal snapshot
|
|
645
|
+
// below, only on the failure path where its state is actually printed.
|
|
646
|
+
const startTime = await this.readTime();
|
|
616
647
|
let lastTick = null;
|
|
617
648
|
let stalledPolls = 0;
|
|
618
649
|
// Fixture heartbeat — same invariant as
|
|
@@ -620,17 +651,23 @@ export class GameClient {
|
|
|
620
651
|
// silence AND the tick having advanced since the last one emitted, so a
|
|
621
652
|
// genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
|
|
622
653
|
// goes heartbeat-silent well before this loop's own guard ever needs to.
|
|
623
|
-
let heartbeat = { lastEmitWallMs: startWall, lastEmitTick:
|
|
654
|
+
let heartbeat = { lastEmitWallMs: startWall, lastEmitTick: startTime.tick };
|
|
624
655
|
for (;;) {
|
|
625
|
-
const
|
|
626
|
-
if (
|
|
656
|
+
const currentTime = await this.readTime();
|
|
657
|
+
if (currentTime.simSeconds - startTime.simSeconds >= budget.simSeconds)
|
|
627
658
|
return;
|
|
628
|
-
stalledPolls = lastTick !== null &&
|
|
629
|
-
lastTick =
|
|
659
|
+
stalledPolls = lastTick !== null && currentTime.tick === lastTick ? stalledPolls + 1 : 0;
|
|
660
|
+
lastTick = currentTime.tick;
|
|
630
661
|
if (stalledPolls >= WAIT_FOR_STALL_POLL_LIMIT) {
|
|
662
|
+
// Failure diagnostics still carry one honest complete terminal snapshot. The hot path above
|
|
663
|
+
// stays clock-only; the expensive provider batch is paid only when it will be printed.
|
|
664
|
+
const current = await this.snapshot();
|
|
631
665
|
throw await this.toSessionFailure(new WaitForTimeoutError({
|
|
632
666
|
budget,
|
|
633
|
-
|
|
667
|
+
// The timeout renderer reads only the start clock; provider state
|
|
668
|
+
// is intentionally terminal-only because no initial provider read
|
|
669
|
+
// occurred. Empty collections state that absence honestly.
|
|
670
|
+
startSnapshot: { time: startTime, state: {}, events: [], pageErrors: [] },
|
|
634
671
|
lastSnapshot: current,
|
|
635
672
|
wallElapsedMs: Date.now() - startWall,
|
|
636
673
|
predicateSource: '(no predicate — game.input.hold is waiting for a sim-time delta to pass)',
|
|
@@ -639,8 +676,8 @@ export class GameClient {
|
|
|
639
676
|
}
|
|
640
677
|
const heartbeatResult = maybeHeartbeat({
|
|
641
678
|
nowMs: Date.now(),
|
|
642
|
-
tick:
|
|
643
|
-
simSeconds:
|
|
679
|
+
tick: currentTime.tick,
|
|
680
|
+
simSeconds: currentTime.simSeconds,
|
|
644
681
|
testTitle: this.#testTitle,
|
|
645
682
|
state: heartbeat,
|
|
646
683
|
});
|
|
@@ -723,7 +760,7 @@ export class GameClient {
|
|
|
723
760
|
*/
|
|
724
761
|
async page(step) {
|
|
725
762
|
const erased = (arg) => step(arg);
|
|
726
|
-
const outcome = await this.#transport.runPageScript(step
|
|
763
|
+
const outcome = await this.#transport.runPageScript(selfContainedStepSource(step), erased);
|
|
727
764
|
return this.unwrap(outcome);
|
|
728
765
|
}
|
|
729
766
|
/**
|
|
@@ -746,7 +783,7 @@ export class GameClient {
|
|
|
746
783
|
*/
|
|
747
784
|
async run(step, opts) {
|
|
748
785
|
const erased = (arg) => step(arg);
|
|
749
|
-
const outcome = await this.#transport.runGameScript(step
|
|
786
|
+
const outcome = await this.#transport.runGameScript(selfContainedStepSource(step), erased, opts?.instance);
|
|
750
787
|
return this.unwrap(outcome);
|
|
751
788
|
}
|
|
752
789
|
/**
|
|
@@ -64,6 +64,11 @@ export interface FastForwardOptions {
|
|
|
64
64
|
/** Overrides `DEFAULT_FAST_FORWARD_BATCH_TICKS` — test-only seam; real
|
|
65
65
|
* callers should leave this unset. */
|
|
66
66
|
batchTicks?: number;
|
|
67
|
+
/** What the caller needs back after the burst. Defaults to the complete
|
|
68
|
+
* debug snapshot. Tick-by-tick orchestration that samples providers only at
|
|
69
|
+
* explicit boundaries selects `'time'` so it does not serialize every large
|
|
70
|
+
* state provider after every staging tick. */
|
|
71
|
+
result?: 'snapshot' | 'time';
|
|
67
72
|
}
|
|
68
73
|
/** One fixed timestep, matching every real host's loop construction
|
|
69
74
|
* (`createGameLoop`'s own `fixedTimestep ?? 1/60` default: "fixedDt = the
|
|
@@ -171,7 +171,10 @@ export class RelayTransport {
|
|
|
171
171
|
}
|
|
172
172
|
async call(method, callArgs) {
|
|
173
173
|
const hidden = await this.preflightHidden();
|
|
174
|
-
|
|
174
|
+
// A predicate-free `waitSimTime` poll reads only the `time` provider. It needs the same hidden
|
|
175
|
+
// tab deterministic driver as a full snapshot or a hidden editor would freeze that lighter
|
|
176
|
+
// clock read forever.
|
|
177
|
+
if (method === 'snapshot' || (method === 'state' && callArgs[0] === 'time')) {
|
|
175
178
|
if (hidden) {
|
|
176
179
|
const now = Date.now();
|
|
177
180
|
const elapsed = this.hiddenDriveLastWallMs === null ? 1000 / 60 : now - this.hiddenDriveLastWallMs;
|
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.42",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"prepack": "npm run build"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@vgai/editor-sdk": "0.5.
|
|
36
|
-
"@vgai/sdk": "0.5.
|
|
35
|
+
"@vgai/editor-sdk": "0.5.42",
|
|
36
|
+
"@vgai/sdk": "0.5.42"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@playwright/test": ">=1.58.2 <2"
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@playwright/test": "^1.58.2",
|
|
43
43
|
"@types/node": "^25.3.0",
|
|
44
|
+
"esbuild": "^0.25.12",
|
|
44
45
|
"typescript": "^5.6.0"
|
|
45
46
|
},
|
|
46
47
|
"description": "Live-session client for VGAI projects: { editor, game, page, tools } over the vgai edit session wire.",
|
package/src/editor.ts
CHANGED
|
@@ -353,6 +353,11 @@ export class LiveEditor {
|
|
|
353
353
|
return this.#client.hierarchy();
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
/** Expand every branch through the Hierarchy panel's own action. */
|
|
357
|
+
async expandHierarchyAll(): Promise<void> {
|
|
358
|
+
await this.#client.expandHierarchyAll();
|
|
359
|
+
}
|
|
360
|
+
|
|
356
361
|
/**
|
|
357
362
|
* Write one editable field from `inspect()` by its stable path, through the
|
|
358
363
|
* same Inspector IO and persistence boundary the human control uses.
|
|
@@ -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
|
|
@@ -628,6 +647,21 @@ export class GameClient {
|
|
|
628
647
|
return snap;
|
|
629
648
|
}
|
|
630
649
|
|
|
650
|
+
/**
|
|
651
|
+
* Read only the simulation clock for a predicate-free wait.
|
|
652
|
+
*
|
|
653
|
+
* `snapshot()` intentionally batches every declared provider for `waitFor`, whose predicate may
|
|
654
|
+
* read any of them. `waitSimTime` has no predicate: serializing a large `unity` provider every
|
|
655
|
+
* 150ms can itself consume multiple frames and corrupt the performance window it is advancing.
|
|
656
|
+
* Keep the two hardening observers fed exactly as an ordinary snapshot does.
|
|
657
|
+
*/
|
|
658
|
+
private async readTime(): Promise<DebugSnapshot['time']> {
|
|
659
|
+
const time = await this.callBridge<DebugSnapshot['time']>('state', 'time');
|
|
660
|
+
this.#tps.record(time.tick, Date.now());
|
|
661
|
+
await this.#hiddenRecovery.observeTick(time.tick);
|
|
662
|
+
return time;
|
|
663
|
+
}
|
|
664
|
+
|
|
631
665
|
/** Fixture-teardown perf read: p50/p95 effective ticks-per-second over all
|
|
632
666
|
* the snapshot polls this test performed. */
|
|
633
667
|
tpsStats(): TpsStats {
|
|
@@ -651,7 +685,15 @@ export class GameClient {
|
|
|
651
685
|
* AFTER the tps baseline reset below, so it never itself corrupts the tps
|
|
652
686
|
* stats either).
|
|
653
687
|
*/
|
|
654
|
-
async fastForward(
|
|
688
|
+
async fastForward(
|
|
689
|
+
budget: FastForwardBudget,
|
|
690
|
+
opts: FastForwardOptions & { result: 'time' },
|
|
691
|
+
): Promise<FastForwardTime>;
|
|
692
|
+
async fastForward(budget: FastForwardBudget, opts?: FastForwardOptions): Promise<DebugSnapshot>;
|
|
693
|
+
async fastForward(
|
|
694
|
+
budget: FastForwardBudget,
|
|
695
|
+
opts?: FastForwardOptions,
|
|
696
|
+
): Promise<DebugSnapshot | FastForwardTime> {
|
|
655
697
|
// Same options-object-only contract as `waitSimTime` (`ticksForBudget`
|
|
656
698
|
// would otherwise fail on `'simTicks' in 0.5` with a raw TypeError that
|
|
657
699
|
// names neither the method nor the shape).
|
|
@@ -681,21 +723,26 @@ export class GameClient {
|
|
|
681
723
|
}
|
|
682
724
|
},
|
|
683
725
|
readTime: async () => {
|
|
684
|
-
// Raw bridge read — deliberately NOT `this.snapshot()`,
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
726
|
+
// Raw, clock-only bridge read — deliberately NOT `this.snapshot()`,
|
|
727
|
+
// which both feeds the TpsAccumulator and serializes every declared
|
|
728
|
+
// provider. Large imported worlds can carry megabytes of census state;
|
|
729
|
+
// the batching math needs only these two numbers.
|
|
730
|
+
const time = await this.callBridge<DebugSnapshot['time']>('state', 'time');
|
|
731
|
+
return { tick: time.tick, simSeconds: time.simSeconds };
|
|
688
732
|
},
|
|
689
733
|
heartbeat: (info) => {
|
|
690
734
|
console.log(`vgai fastForward: ${info.ticksDone}/${info.ticksTotal} ticks driven`);
|
|
691
735
|
},
|
|
692
736
|
};
|
|
693
|
-
await runFastForward(budget, opts ?? {}, clock);
|
|
737
|
+
const finalTime = await runFastForward(budget, opts ?? {}, clock);
|
|
694
738
|
// The burst is over — reset the baseline so the very next ordinary poll
|
|
695
739
|
// (including the `snapshot()` call right below) treats itself as a fresh
|
|
696
740
|
// "first observation" rather than diffing across the burst's enormous
|
|
697
741
|
// tick delta over a near-zero wall delta.
|
|
698
742
|
this.#tps.resetBaseline();
|
|
743
|
+
if (opts?.result === 'time') {
|
|
744
|
+
return finalTime;
|
|
745
|
+
}
|
|
699
746
|
return this.snapshot();
|
|
700
747
|
}
|
|
701
748
|
|
|
@@ -745,7 +792,13 @@ export class GameClient {
|
|
|
745
792
|
// fires either. Refuse in the caller's own vocabulary instead of hanging.
|
|
746
793
|
assertValidWaitForBudget(budget, 'waitSimTime');
|
|
747
794
|
const startWall = Date.now();
|
|
748
|
-
|
|
795
|
+
// Predicate-free means clock-only from the FIRST read, not merely from the
|
|
796
|
+
// second poll onward. A full initial snapshot serializes every provider;
|
|
797
|
+
// the Unity FPS provider alone carries ~4,500 objects and measured a
|
|
798
|
+
// 150-300ms main-thread hitch each time a caller began an otherwise cheap
|
|
799
|
+
// wait. Failure diagnostics still take one complete terminal snapshot
|
|
800
|
+
// below, only on the failure path where its state is actually printed.
|
|
801
|
+
const startTime = await this.readTime();
|
|
749
802
|
let lastTick: number | null = null;
|
|
750
803
|
let stalledPolls = 0;
|
|
751
804
|
// Fixture heartbeat — same invariant as
|
|
@@ -753,17 +806,23 @@ export class GameClient {
|
|
|
753
806
|
// silence AND the tick having advanced since the last one emitted, so a
|
|
754
807
|
// genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
|
|
755
808
|
// goes heartbeat-silent well before this loop's own guard ever needs to.
|
|
756
|
-
let heartbeat: HeartbeatState = { lastEmitWallMs: startWall, lastEmitTick:
|
|
809
|
+
let heartbeat: HeartbeatState = { lastEmitWallMs: startWall, lastEmitTick: startTime.tick };
|
|
757
810
|
for (;;) {
|
|
758
|
-
const
|
|
759
|
-
if (
|
|
760
|
-
stalledPolls = lastTick !== null &&
|
|
761
|
-
lastTick =
|
|
811
|
+
const currentTime = await this.readTime();
|
|
812
|
+
if (currentTime.simSeconds - startTime.simSeconds >= budget.simSeconds) return;
|
|
813
|
+
stalledPolls = lastTick !== null && currentTime.tick === lastTick ? stalledPolls + 1 : 0;
|
|
814
|
+
lastTick = currentTime.tick;
|
|
762
815
|
if (stalledPolls >= WAIT_FOR_STALL_POLL_LIMIT) {
|
|
816
|
+
// Failure diagnostics still carry one honest complete terminal snapshot. The hot path above
|
|
817
|
+
// stays clock-only; the expensive provider batch is paid only when it will be printed.
|
|
818
|
+
const current = await this.snapshot();
|
|
763
819
|
throw await this.toSessionFailure(
|
|
764
820
|
new WaitForTimeoutError({
|
|
765
821
|
budget,
|
|
766
|
-
|
|
822
|
+
// The timeout renderer reads only the start clock; provider state
|
|
823
|
+
// is intentionally terminal-only because no initial provider read
|
|
824
|
+
// occurred. Empty collections state that absence honestly.
|
|
825
|
+
startSnapshot: { time: startTime, state: {}, events: [], pageErrors: [] },
|
|
767
826
|
lastSnapshot: current,
|
|
768
827
|
wallElapsedMs: Date.now() - startWall,
|
|
769
828
|
predicateSource:
|
|
@@ -774,8 +833,8 @@ export class GameClient {
|
|
|
774
833
|
}
|
|
775
834
|
const heartbeatResult = maybeHeartbeat({
|
|
776
835
|
nowMs: Date.now(),
|
|
777
|
-
tick:
|
|
778
|
-
simSeconds:
|
|
836
|
+
tick: currentTime.tick,
|
|
837
|
+
simSeconds: currentTime.simSeconds,
|
|
779
838
|
testTitle: this.#testTitle,
|
|
780
839
|
state: heartbeat,
|
|
781
840
|
});
|
|
@@ -858,7 +917,10 @@ export class GameClient {
|
|
|
858
917
|
*/
|
|
859
918
|
async page<T = unknown>(step: (page: Page) => T | Promise<T>): Promise<T> {
|
|
860
919
|
const erased = (arg: unknown) => step(arg as Page);
|
|
861
|
-
const outcome = await this.#transport.runPageScript(
|
|
920
|
+
const outcome = await this.#transport.runPageScript(
|
|
921
|
+
selfContainedStepSource(step as (scope: unknown) => unknown),
|
|
922
|
+
erased,
|
|
923
|
+
);
|
|
862
924
|
return this.unwrap<T>(outcome);
|
|
863
925
|
}
|
|
864
926
|
|
|
@@ -889,7 +951,11 @@ export class GameClient {
|
|
|
889
951
|
opts?: { instance?: string },
|
|
890
952
|
): Promise<T> {
|
|
891
953
|
const erased = (arg: unknown) => step(arg as Parameters<typeof step>[0]);
|
|
892
|
-
const outcome = await this.#transport.runGameScript(
|
|
954
|
+
const outcome = await this.#transport.runGameScript(
|
|
955
|
+
selfContainedStepSource(step as (scope: unknown) => unknown),
|
|
956
|
+
erased,
|
|
957
|
+
opts?.instance,
|
|
958
|
+
);
|
|
893
959
|
return this.unwrap<T>(outcome);
|
|
894
960
|
}
|
|
895
961
|
|
|
@@ -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
|
|
@@ -232,7 +232,10 @@ export class RelayTransport implements BridgeTransport {
|
|
|
232
232
|
|
|
233
233
|
async call(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome> {
|
|
234
234
|
const hidden = await this.preflightHidden();
|
|
235
|
-
|
|
235
|
+
// A predicate-free `waitSimTime` poll reads only the `time` provider. It needs the same hidden
|
|
236
|
+
// tab deterministic driver as a full snapshot or a hidden editor would freeze that lighter
|
|
237
|
+
// clock read forever.
|
|
238
|
+
if (method === 'snapshot' || (method === 'state' && callArgs[0] === 'time')) {
|
|
236
239
|
if (hidden) {
|
|
237
240
|
const now = Date.now();
|
|
238
241
|
const elapsed =
|