@vgai/live 0.5.2 → 0.5.4

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.
Files changed (62) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/editor.d.ts +78 -55
  3. package/dist/editor.js +129 -76
  4. package/dist/game-client/bridge-heartbeat.d.ts +49 -0
  5. package/dist/game-client/bridge-heartbeat.js +46 -0
  6. package/dist/game-client/bridge-transport.d.ts +75 -0
  7. package/dist/game-client/bridge-transport.js +19 -0
  8. package/dist/game-client/client.d.ts +293 -0
  9. package/dist/game-client/client.js +706 -0
  10. package/dist/game-client/errors.d.ts +57 -0
  11. package/dist/game-client/errors.js +76 -0
  12. package/dist/game-client/events-matcher.d.ts +41 -0
  13. package/dist/game-client/events-matcher.js +68 -0
  14. package/dist/game-client/failure-block.d.ts +93 -0
  15. package/dist/game-client/failure-block.js +97 -0
  16. package/dist/game-client/fast-forward.d.ts +125 -0
  17. package/dist/game-client/fast-forward.js +122 -0
  18. package/dist/game-client/hidden-recovery.d.ts +85 -0
  19. package/dist/game-client/hidden-recovery.js +105 -0
  20. package/dist/game-client/index.d.ts +40 -0
  21. package/dist/game-client/index.js +26 -0
  22. package/dist/game-client/perf-sampling.d.ts +56 -0
  23. package/dist/game-client/perf-sampling.js +85 -0
  24. package/dist/game-client/relay-transport.d.ts +100 -0
  25. package/dist/game-client/relay-transport.js +237 -0
  26. package/dist/game-client/screenshot-target.d.ts +60 -0
  27. package/dist/game-client/screenshot-target.js +68 -0
  28. package/dist/game-client/state-cap.d.ts +7 -0
  29. package/dist/game-client/state-cap.js +21 -0
  30. package/dist/game-client/types.d.ts +128 -0
  31. package/dist/game-client/types.js +15 -0
  32. package/dist/game-client/wait-for.d.ts +155 -0
  33. package/dist/game-client/wait-for.js +229 -0
  34. package/dist/game.d.ts +47 -18
  35. package/dist/game.js +59 -16
  36. package/dist/index.d.ts +46 -21
  37. package/dist/index.js +51 -20
  38. package/dist/session.d.ts +4 -4
  39. package/dist/session.js +7 -7
  40. package/dist/tools.d.ts +12 -3
  41. package/dist/tools.js +15 -6
  42. package/package.json +10 -5
  43. package/src/editor.ts +142 -96
  44. package/src/game-client/bridge-heartbeat.ts +61 -0
  45. package/src/game-client/bridge-transport.ts +73 -0
  46. package/src/game-client/client.ts +836 -0
  47. package/src/game-client/errors.ts +96 -0
  48. package/src/game-client/events-matcher.ts +106 -0
  49. package/src/game-client/failure-block.ts +199 -0
  50. package/src/game-client/fast-forward.ts +175 -0
  51. package/src/game-client/hidden-recovery.ts +149 -0
  52. package/src/game-client/index.ts +98 -0
  53. package/src/game-client/perf-sampling.ts +94 -0
  54. package/src/game-client/relay-transport.ts +311 -0
  55. package/src/game-client/screenshot-target.ts +91 -0
  56. package/src/game-client/state-cap.ts +29 -0
  57. package/src/game-client/types.ts +137 -0
  58. package/src/game-client/wait-for.ts +327 -0
  59. package/src/game.ts +96 -16
  60. package/src/index.ts +68 -31
  61. package/src/session.ts +8 -10
  62. package/src/tools.ts +19 -6
@@ -0,0 +1,96 @@
1
+ import type { SessionFailureData } from './failure-block.js';
2
+
3
+ /**
4
+ * Machine-readable error codes this client throws. `INPUT_GATED` is the same
5
+ * frozen token `@vgai/sdk`'s input operations declare, so a caller classifies
6
+ * a gated actuation identically whichever door it came through. `WAIT_FOR_*`
7
+ * and `EVENTS_EXPECT_FAILED` are this client's own extension of the same
8
+ * "errors carry machine-readable `code` fields" rule: nothing in the codebase
9
+ * may identify a failure by parsing message prose, so an assertion failure
10
+ * gets a code too.
11
+ */
12
+ export const SESSION_ERROR_CODES = {
13
+ INPUT_GATED: 'INPUT_GATED',
14
+ WAIT_FOR_INVALID_BUDGET: 'WAIT_FOR_INVALID_BUDGET',
15
+ WAIT_FOR_TIMEOUT: 'WAIT_FOR_TIMEOUT',
16
+ EVENTS_EXPECT_FAILED: 'EVENTS_EXPECT_FAILED',
17
+ } as const;
18
+
19
+ export type SessionErrorCode = (typeof SESSION_ERROR_CODES)[keyof typeof SESSION_ERROR_CODES];
20
+
21
+ /**
22
+ * Base error for this client. `code` is always machine-readable; `data` (if
23
+ * any) is structured, never prose-only. Bridge-surfaced errors (unknown
24
+ * provider/command/action names) are re-thrown as this class carrying the
25
+ * bridge's own `code`/`data` verbatim — see `client.ts`'s `invokeBridge`.
26
+ */
27
+ export class SessionError extends Error {
28
+ readonly code: string;
29
+ readonly data: unknown;
30
+
31
+ constructor(code: string, message: string, data?: unknown) {
32
+ super(message);
33
+ this.name = 'SessionError';
34
+ this.code = code;
35
+ this.data = data;
36
+ }
37
+ }
38
+
39
+ /** A `delivered:false` virtual-input actuation fails loudly and immediately
40
+ * (Task 3.2 item: "game.input hold-tap-set"). */
41
+ export function inputGatedError(action: string, reason: string | undefined): SessionError {
42
+ return new SessionError(
43
+ SESSION_ERROR_CODES.INPUT_GATED,
44
+ `INPUT_GATED: action "${action}" was not delivered — ${reason ?? 'input gated'}`,
45
+ { action, reason },
46
+ );
47
+ }
48
+
49
+ /** The exact `code` the engine's debug registry throws for an unknown
50
+ * command name (`packages/engine/src/runtime/debug-registry.ts`) — not one
51
+ * of this client's OWN `SESSION_ERROR_CODES` above (it originates on the
52
+ * page side and crosses the bridge verbatim via `client.ts`'s `unwrap`),
53
+ * named here so `appendWarmSessionHint` below doesn't compare against a
54
+ * bare string literal. */
55
+ const DEBUG_COMMAND_NOT_REGISTERED_CODE = 'DEBUG_COMMAND_NOT_REGISTERED';
56
+
57
+ /** Cheapest honest fix for a blind-validation finding: a run against a WARM
58
+ * game server (one already up before this client attached) gives a
59
+ * `DEBUG_COMMAND_NOT_REGISTERED` failure an extra way to be true besides
60
+ * "this command was never registered" — the agent may have added the command
61
+ * to the game's OWN code AFTER the warm server already booted, so the running
62
+ * server simply predates it. Without a live page handle this client can't
63
+ * tell the two apart either, so the honest move is naming the possibility,
64
+ * not guessing. Appended ONLY for that exact (code, warm) combination — never
65
+ * on a fresh boot, and never for any other error code, so an ordinary "you
66
+ * never wrote this command" failure stays exactly as terse as before. Pure so
67
+ * the message logic is testable without a real bridge round trip. */
68
+ export const WARM_SESSION_STALE_COMMAND_NOTE =
69
+ 'note: this run reused a warm game server — if you added this command since the server ' +
70
+ 'booted, restart the server and re-run';
71
+
72
+ export function appendWarmSessionHint(
73
+ message: string,
74
+ code: string | undefined,
75
+ warm: boolean,
76
+ ): string {
77
+ if (!warm || code !== DEBUG_COMMAND_NOT_REGISTERED_CODE) return message;
78
+ return `${message}\n${WARM_SESSION_STALE_COMMAND_NOTE}`;
79
+ }
80
+
81
+ /**
82
+ * A `game.waitFor` / `game.input.hold` / `game.events.expect` failure. Always
83
+ * carries the full eight-member failure block (Task 3.3) both as the
84
+ * rendered assertion `message` and as structured `data` for a JSON reporter.
85
+ */
86
+ export class SessionFailure extends Error {
87
+ readonly code: string;
88
+ readonly data: SessionFailureData;
89
+
90
+ constructor(code: string, block: { message: string; data: SessionFailureData }) {
91
+ super(block.message);
92
+ this.name = 'SessionFailure';
93
+ this.code = code;
94
+ this.data = block.data;
95
+ }
96
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Pure `game.events.expect` matcher — no Playwright. Default mode is an
3
+ * ordered SUBSEQUENCE match (extra actual events are tolerated, including
4
+ * interleaved ones); `{ exact: true }` requires the actual (fenced) event
5
+ * window to equal `expected` position-for-position with nothing extra.
6
+ */
7
+
8
+ import type { TickStampedEvent } from './types.js';
9
+
10
+ export interface EventsMatchOptions {
11
+ exact?: boolean;
12
+ /** All matched expected events must land within this many ticks of the
13
+ * fence (the snapshot `actual` was already filtered to). Optional —
14
+ * unset means no timing constraint beyond ordering. */
15
+ withinTicks?: number;
16
+ }
17
+
18
+ export interface EventsMatchResult {
19
+ matched: boolean;
20
+ /** Index into `expected` of the first expectation the match couldn't
21
+ * satisfy; `null` when `matched` is true. */
22
+ firstUnmatchedIndex: number | null;
23
+ }
24
+
25
+ /**
26
+ * M9: `fenceTick` is the REAL fence (the tick `game.events.expect` fenced
27
+ * `actual` from — `GameClient.fenceTick`, i.e. the test's own start), not
28
+ * `actual[0].tick`. Using the first EVENT's tick as the fence under-counts
29
+ * `withinTicks`: if the fence is tick 10 and the first matching event
30
+ * happens at tick 50 (40 ticks after the fence), `actual[0].tick` would make
31
+ * that look like "0 ticks since the fence" instead of 40.
32
+ *
33
+ * It is REQUIRED, and comes BEFORE `opts`. It used to be a trailing optional
34
+ * with an `actual[0]?.tick ?? 0` back-compat default — i.e. the very
35
+ * under-counting bug above, silently reinstated for anyone who forgot the
36
+ * argument. The one production caller always had a real fence, so the
37
+ * default only ever existed to be wrong. Also (M9): `{ exact: true }` honors
38
+ * `withinTicks` too — previously only the default subsequence mode checked
39
+ * it at all.
40
+ */
41
+ export function matchEventsSubsequence(
42
+ expected: string[],
43
+ actual: TickStampedEvent[],
44
+ fenceTick: number,
45
+ opts: EventsMatchOptions = {},
46
+ ): EventsMatchResult {
47
+ if (opts.exact) {
48
+ return matchExact(expected, actual, opts.withinTicks, fenceTick);
49
+ }
50
+ return matchSubsequence(expected, actual, opts.withinTicks, fenceTick);
51
+ }
52
+
53
+ function matchSubsequence(
54
+ expected: string[],
55
+ actual: TickStampedEvent[],
56
+ withinTicks: number | undefined,
57
+ fenceTick: number,
58
+ ): EventsMatchResult {
59
+ let expectedIdx = 0;
60
+ for (const e of actual) {
61
+ const wantName = expected[expectedIdx];
62
+ if (wantName === undefined) break;
63
+ if (e.event !== wantName) continue;
64
+ if (withinTicks !== undefined && e.tick - fenceTick > withinTicks) continue;
65
+ expectedIdx += 1;
66
+ }
67
+ if (expectedIdx >= expected.length) {
68
+ return { matched: true, firstUnmatchedIndex: null };
69
+ }
70
+ return { matched: false, firstUnmatchedIndex: expectedIdx };
71
+ }
72
+
73
+ function matchExact(
74
+ expected: string[],
75
+ actual: TickStampedEvent[],
76
+ withinTicks: number | undefined,
77
+ fenceTick: number,
78
+ ): EventsMatchResult {
79
+ const len = Math.max(expected.length, actual.length);
80
+ for (let i = 0; i < len; i++) {
81
+ const want = expected[i];
82
+ const gotEvent = actual[i];
83
+ if (want !== gotEvent?.event) {
84
+ return { matched: false, firstUnmatchedIndex: Math.min(i, Math.max(expected.length - 1, 0)) };
85
+ }
86
+ if (
87
+ withinTicks !== undefined &&
88
+ gotEvent !== undefined &&
89
+ gotEvent.tick - fenceTick > withinTicks
90
+ ) {
91
+ return { matched: false, firstUnmatchedIndex: Math.min(i, Math.max(expected.length - 1, 0)) };
92
+ }
93
+ }
94
+ return { matched: true, firstUnmatchedIndex: null };
95
+ }
96
+
97
+ /** Renders the "predicate" line `events.expect` uses in its failure block —
98
+ * there is no user predicate function for this assertion, so the failure
99
+ * block's generic `predicateSource` slot gets a description instead. */
100
+ export function describeEventsExpectation(
101
+ expected: string[],
102
+ opts: EventsMatchOptions = {},
103
+ ): string {
104
+ const optsText = opts.exact ? ', { exact: true }' : '';
105
+ return `events.expect(${JSON.stringify(expected)}${optsText})`;
106
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Pure failure-block assembly (Task 3.3) — no Playwright. Every
3
+ * driver-originated failure (`game.waitFor` timeout, `game.events.expect`
4
+ * mismatch) renders through this one assembler so the frozen eight-member
5
+ * contract is met identically everywhere: elapsed (sim/wall/ratio+hint),
6
+ * tick, predicate/expectation source, tier-annotated + byte-capped last
7
+ * state, last 8 events, screenshot path, console errors, page errors. (M13
8
+ * review fix adds one MORE line, hidden-tab recovery notices, kept
9
+ * deliberately separate from the console/page-errors member rather than
10
+ * folded into it — see `recoveryNotices` below.)
11
+ */
12
+
13
+ import { capJson } from './state-cap.js';
14
+ import type { ProviderInfo, TickStampedEvent } from './types.js';
15
+
16
+ export interface FailureBlockContext {
17
+ /** First line — e.g. "game.waitFor timed out: budget 10 sim-seconds". */
18
+ headline: string;
19
+ simElapsedSeconds: number;
20
+ wallElapsedMs: number;
21
+ tick: number;
22
+ /** `pred.toString()` for waitFor; a rendered description for events.expect. */
23
+ predicateSource: string;
24
+ providers: ProviderInfo[];
25
+ /** Provider names the predicate actually read (waitFor only) — drives the
26
+ * "⚠ proof consumed assisted state" header flag. Omitted (or empty) for
27
+ * assertions with no predicate function, e.g. events.expect. */
28
+ touchedProviders?: string[];
29
+ lastState: Record<string, unknown>;
30
+ lastEvents: TickStampedEvent[];
31
+ screenshotPath: string | null;
32
+ consoleErrors: string[];
33
+ pageErrors: string[];
34
+ /** M13: hidden-tab recovery's own structured log lines (hidden-recovery.ts's
35
+ * `hiddenRecoveryLogLine`), kept SEPARATE from `consoleErrors` — a
36
+ * recovery notice ("bringToFront() fired") is not itself an error, and
37
+ * conflating it with real console/page errors muddies the one section a
38
+ * reader scans to tell "the game crashed" from "the tab was backgrounded
39
+ * and got recovered". Optional/defaults to empty — most failures have
40
+ * none. */
41
+ recoveryNotices?: string[];
42
+ /**
43
+ * Issue #175 — the REAL loop liveness at the moment of failure (the
44
+ * timed-out/failed snapshot's `time.loopLiveness`). When this is
45
+ * `'hidden-paused'`, `ratioLine` below replaces its generic "if ~0x, the
46
+ * loop is stalled" hint with an explicit "the tab is hidden" diagnosis —
47
+ * `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
48
+ * before this failure was ever thrown (see `client.ts`/`hidden-recovery.ts`);
49
+ * a 0.00x ratio surviving past that recovery attempt is exactly the
50
+ * ambiguous reading this field resolves: a backgrounded tab the recovery
51
+ * couldn't reach (headless/no-op `bringToFront`), not proof the game
52
+ * itself crashed. Optional/`undefined` when the bridge build predates
53
+ * `loopLiveness` — falls back to the old generic hint, same as before.
54
+ */
55
+ loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null | undefined;
56
+ }
57
+
58
+ export interface SessionFailureData {
59
+ headline: string;
60
+ elapsed: { simSeconds: number; wallMs: number; ratio: number; line: string };
61
+ tick: number;
62
+ predicateSource: string;
63
+ lastState: { text: string; truncated: boolean };
64
+ lastEvents: TickStampedEvent[];
65
+ screenshotPath: string | null;
66
+ consoleErrors: string[];
67
+ pageErrors: string[];
68
+ /** Non-empty when the predicate consumed an `assisted`-tier provider. */
69
+ assistedTierConsumed: string[];
70
+ /** M13: see `FailureBlockContext.recoveryNotices`. Always present (an
71
+ * empty array, not omitted) so a JSON reporter never has to distinguish
72
+ * "no notices" from "field absent". */
73
+ recoveryNotices: string[];
74
+ /** Issue #175 — see `FailureBlockContext.loopLiveness`'s doc comment.
75
+ * Machine-readable twin of `elapsed.line`'s hidden-tab hint, for a JSON
76
+ * reporter that doesn't want to parse prose to tell a frozen tab from a
77
+ * stalled game. `undefined`/`null` exactly mirrors the context field. */
78
+ loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null | undefined;
79
+ }
80
+
81
+ export interface AssembledFailureBlock {
82
+ message: string;
83
+ data: SessionFailureData;
84
+ }
85
+
86
+ /** Annotates each top-level state key with its provider tier, e.g.
87
+ * `"inventory[observable]"`. Providers with no matching registration
88
+ * (shouldn't normally happen — `stateAll()` and `providers()` are read from
89
+ * the same registry) are marked `[unknown]` rather than dropped. */
90
+ export function annotateStateTiers(
91
+ state: Record<string, unknown>,
92
+ providers: ProviderInfo[],
93
+ ): Record<string, unknown> {
94
+ const tierByName = new Map(providers.map((p) => [p.name, p.tier]));
95
+ const out: Record<string, unknown> = {};
96
+ for (const [key, value] of Object.entries(state)) {
97
+ const tier = tierByName.get(key) ?? 'unknown';
98
+ out[`${key}[${tier}]`] = value;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function ratioLine(
104
+ simElapsedSeconds: number,
105
+ wallElapsedMs: number,
106
+ loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null,
107
+ ): { ratio: number; line: string } {
108
+ const wallElapsedS = wallElapsedMs / 1000;
109
+ const ratio = wallElapsedS > 0 ? simElapsedSeconds / wallElapsedS : 0;
110
+ // Issue #175: a hidden tab hard-stops the engine loop (T2.1's deliberate
111
+ // idle throttle) — the same ~0x reading a genuinely stalled/crashed game
112
+ // produces. Without this branch an agent sees "loop is stalled" and has no
113
+ // way to tell a frozen tab from a dead game; say so explicitly instead.
114
+ const hint =
115
+ loopLiveness === 'hidden-paused'
116
+ ? 'the EDITOR/BROWSER TAB IS HIDDEN — the engine loop deliberately ' +
117
+ 'stops ticking while backgrounded (not a crash); bring the tab to ' +
118
+ 'the foreground and retry'
119
+ : 'if ~0x, the loop is stalled; if <1x under CI, the budget may just ' +
120
+ 'be too small for SwiftShader';
121
+ const line =
122
+ `sim-time elapsed: ${simElapsedSeconds.toFixed(2)}s over ${wallElapsedS.toFixed(1)}s wall ` +
123
+ `(sim speed ${ratio.toFixed(2)}x — ${hint})`;
124
+ return { ratio, line };
125
+ }
126
+
127
+ export function assembleFailureBlock(ctx: FailureBlockContext): AssembledFailureBlock {
128
+ const tierByName = new Map(ctx.providers.map((p) => [p.name, p.tier]));
129
+ const touched = ctx.touchedProviders ?? [];
130
+ const assistedTierConsumed = touched.filter((name) => tierByName.get(name) === 'assisted');
131
+
132
+ const annotatedState = annotateStateTiers(ctx.lastState, ctx.providers);
133
+ const capped = capJson(annotatedState);
134
+ const last8Events = ctx.lastEvents.slice(-8);
135
+ const { ratio, line: elapsedLine } = ratioLine(
136
+ ctx.simElapsedSeconds,
137
+ ctx.wallElapsedMs,
138
+ ctx.loopLiveness,
139
+ );
140
+
141
+ const eventsLine =
142
+ last8Events.length > 0
143
+ ? last8Events
144
+ .map(
145
+ (e) =>
146
+ `[t=${e.tick}] ${e.event}${e.detail !== undefined ? ` ${JSON.stringify(e.detail)}` : ''}`,
147
+ )
148
+ .join(' ')
149
+ : '(none)';
150
+
151
+ const consoleAndPageErrorsText =
152
+ ctx.consoleErrors.length + ctx.pageErrors.length > 0
153
+ ? [...ctx.consoleErrors, ...ctx.pageErrors].join('; ')
154
+ : '(none)';
155
+
156
+ const recoveryNotices = ctx.recoveryNotices ?? [];
157
+ const recoveryNoticesText = recoveryNotices.length > 0 ? recoveryNotices.join('; ') : '(none)';
158
+
159
+ const lines = [
160
+ ctx.headline,
161
+ elapsedLine,
162
+ `tick at failure: ${ctx.tick}`,
163
+ `predicate: ${ctx.predicateSource}`,
164
+ `last state (tier-annotated): ${capped.text}${capped.truncated ? ' [truncated]' : ''}`,
165
+ `last 8 events: ${eventsLine}`,
166
+ `screenshot: ${ctx.screenshotPath ?? '(none)'}`,
167
+ `console/page errors during test: ${consoleAndPageErrorsText}`,
168
+ // M13: its own line, deliberately separate from the console/page errors
169
+ // line above — a hidden-tab recovery notice is not itself an error.
170
+ `hidden-tab recovery notices: ${recoveryNoticesText}`,
171
+ ];
172
+
173
+ const header =
174
+ assistedTierConsumed.length > 0
175
+ ? `⚠ proof consumed assisted (X-ray) state: ${assistedTierConsumed.join(', ')}\n`
176
+ : '';
177
+
178
+ const data: SessionFailureData = {
179
+ headline: ctx.headline,
180
+ elapsed: {
181
+ simSeconds: ctx.simElapsedSeconds,
182
+ wallMs: ctx.wallElapsedMs,
183
+ ratio,
184
+ line: elapsedLine,
185
+ },
186
+ tick: ctx.tick,
187
+ predicateSource: ctx.predicateSource,
188
+ lastState: capped,
189
+ lastEvents: last8Events,
190
+ screenshotPath: ctx.screenshotPath,
191
+ consoleErrors: ctx.consoleErrors,
192
+ pageErrors: ctx.pageErrors,
193
+ assistedTierConsumed,
194
+ recoveryNotices,
195
+ loopLiveness: ctx.loopLiveness,
196
+ };
197
+
198
+ return { message: header + lines.join('\n'), data };
199
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Pure `game.fastForward` batching/accounting math — no Playwright (same
3
+ * "pure module, Playwright-free" split `wait-for.ts` established, so this
4
+ * unit-tests headlessly). `client.ts`'s `GameClient.fastForward` is the
5
+ * thin Playwright-bound wrapper around `runFastForward` below, exactly the
6
+ * way `GameClient.waitFor` wraps `runWaitFor`.
7
+ *
8
+ * The shape: `game.fastForward({simSeconds}|{simTicks})` synchronously
9
+ * drives the live `Game`'s `runTicks` (through the debug bridge, door (a))
10
+ * instead of relying on real wall-clock time to pass. Doctrine:
11
+ * fastForward is for SETUP/STAGING traversal —
12
+ * reaching a known late-game state fast — not a replacement for real-input
13
+ * proofs, which still run in real ticks; a scripted/scheduled input sequence
14
+ * driven THROUGH a fastForward burst is still an honest proof of "what the sim
15
+ * consumed" (the post-gate recording framing) — fastForward changes
16
+ * how fast ticks are produced, never what a tick consumes.
17
+ *
18
+ * Two honesty decisions this module encodes, both because burst ticks are NOT
19
+ * ordinary wall-clock-paced simulation and must never be reported as if they
20
+ * were (the "never fabricate" rule threaded through `perf-sampling.ts`/
21
+ * `failure-block.ts`):
22
+ *
23
+ * 1. **Batching, not one giant `runTicks` call.** A single call driving (say)
24
+ * an hour of sim time would (a) block the page's JS thread for the ENTIRE
25
+ * burst with zero observable progress, and (b) starve anything watching
26
+ * this run's stdout for liveness for however long the burst takes — a
27
+ * false "wedged" verdict on a run that was making fine (just silent)
28
+ * progress. Batching into `DEFAULT_FAST_FORWARD_BATCH_TICKS`-sized chunks
29
+ * with one heartbeat per chunk (`client.ts` prints it) keeps output flowing
30
+ * without giving up the speedup — each batch is still driven synchronously
31
+ * in-page; only the ROUND TRIP is chunked.
32
+ * 2. **Burst ticks never feed the ordinary tps stats.** `perf-sampling.ts`'s
33
+ * `TpsAccumulator` measures REAL wall-clock throughput between ordinary
34
+ * `snapshot()` polls — its whole purpose is "is the game's frame loop
35
+ * keeping up in real time." A burst of, say, 1800 ticks completed in
36
+ * ~50ms would produce a `~36000 ticks/s` sample that means nothing about
37
+ * the game's real per-frame cost, and would silently corrupt the p50/p95
38
+ * a genuine stall would otherwise surface in. `client.ts`'s `fastForward`
39
+ * therefore drives every batch through a RAW bridge call (bypassing
40
+ * `GameClient.snapshot()`'s automatic `TpsAccumulator.record`) and resets
41
+ * the accumulator's baseline once the burst completes, so the very next
42
+ * ordinary poll starts a fresh "first observation" instead of diffing
43
+ * across the burst discontinuity. The whole-TEST `simSpeedRatio` (fixture
44
+ * teardown, `perf-sampling.ts`) is NOT protected this way, deliberately:
45
+ * it is an honest end-to-end ratio (fence snapshot -> final snapshot),
46
+ * not a sampled rate, and showing "this test ran at 400x" when it
47
+ * genuinely did IS the correct, undistorted signal. `waitFor` budgets are
48
+ * themselves unaffected by any of this — they are still sim-time
49
+ * budgets; a `fastForward` immediately before one just leaves less real
50
+ * ground for it to cover (sim-time got CHEAPER, not redefined).
51
+ */
52
+
53
+ export type FastForwardBudget = { simSeconds: number } | { simTicks: number };
54
+
55
+ export type FastForwardRenderMode = 'last' | 'all' | 'none';
56
+
57
+ export interface FastForwardOptions {
58
+ /** Render mode for the FINAL tick of the WHOLE fast-forward (default
59
+ * `'last'`, matching `Game.runTicks`'s own default) — every intermediate
60
+ * batch always forces `'none'` regardless of this value; see the module
61
+ * doc's batching rationale (point 1). */
62
+ render?: FastForwardRenderMode;
63
+ /** Overrides `DEFAULT_FAST_FORWARD_BATCH_TICKS` — test-only seam; real
64
+ * callers should leave this unset. */
65
+ batchTicks?: number;
66
+ }
67
+
68
+ /** One fixed timestep, matching every real host's loop construction
69
+ * (`createGameLoop`'s own `fixedTimestep ?? 1/60` default: "fixedDt = the
70
+ * host loop's fixed timestep (1/60)"). Used ONLY as a fallback when a
71
+ * `simSeconds` budget needs converting to a tick count before the game has
72
+ * ticked even once (nothing observed yet to measure the real fixedDt from) —
73
+ * see `ticksForBudget`. */
74
+ export const DEFAULT_FIXED_DT = 1 / 60;
75
+
76
+ /** 5 sim-seconds' worth of ticks at the default 60Hz rate — small enough that
77
+ * even a complex game's batch finishes well inside a second of real time
78
+ * (keeping heartbeats frequent), large enough that round-trip overhead stays
79
+ * negligible next to the speedup (module doc, point 1). */
80
+ export const DEFAULT_FAST_FORWARD_BATCH_TICKS = 300;
81
+
82
+ export interface FastForwardTime {
83
+ tick: number;
84
+ simSeconds: number;
85
+ }
86
+
87
+ /** The driver's I/O seam — real usage (`client.ts`) wires this to raw
88
+ * (non-tps-recording) bridge calls; tests supply a scripted fake so this
89
+ * module's batching/accounting logic is provable with no browser at all. */
90
+ export interface FastForwardClock {
91
+ /** Synchronously runs `n` fixed ticks with the given render mode for THIS
92
+ * batch (the driver, not the clock, decides which batch is final and
93
+ * therefore gets the caller's requested render mode). */
94
+ runTicksBatch(n: number, render: FastForwardRenderMode): Promise<void>;
95
+ /** Reads the current `{tick, simSeconds}` WITHOUT recording a tps sample —
96
+ * called once up front (to derive `fixedDt` for a `simSeconds` budget) and
97
+ * once at the very end (the returned result). */
98
+ readTime(): Promise<FastForwardTime>;
99
+ /** One call per completed batch — real usage prints a heartbeat line
100
+ * (reaching the CLI wedge watchdog's stdout-liveness check); tests just
101
+ * record the calls. */
102
+ heartbeat(info: { ticksDone: number; ticksTotal: number }): void;
103
+ }
104
+
105
+ /** Converts a `simSeconds` budget into an exact tick count using the game's
106
+ * OWN observed `simSeconds`/`tick` ratio — not a hardcoded constant, since a
107
+ * project may run a non-default fixed timestep (see `simulate-cinematic`'s
108
+ * `vgai-simulate-fixed-dt` precedent); measuring beats assuming. Falls back
109
+ * to `DEFAULT_FIXED_DT` only when `observed.tick` is still `0` (nothing to
110
+ * measure from yet — a fresh page that hasn't ticked once). A `simTicks`
111
+ * budget passes straight through, unaffected by any of this. */
112
+ export function ticksForBudget(budget: FastForwardBudget, observed: FastForwardTime): number {
113
+ if ('simTicks' in budget) {
114
+ if (!Number.isInteger(budget.simTicks) || budget.simTicks < 0) {
115
+ throw new RangeError(
116
+ `game.fastForward: simTicks must be a non-negative integer, got ${budget.simTicks}`,
117
+ );
118
+ }
119
+ return budget.simTicks;
120
+ }
121
+ if (!Number.isFinite(budget.simSeconds) || budget.simSeconds < 0) {
122
+ throw new RangeError(`game.fastForward: simSeconds must be >= 0, got ${budget.simSeconds}`);
123
+ }
124
+ const fixedDt = observed.tick > 0 ? observed.simSeconds / observed.tick : DEFAULT_FIXED_DT;
125
+ return Math.ceil(budget.simSeconds / fixedDt);
126
+ }
127
+
128
+ /** Pure batch-size planner: splits `totalTicks` into chunks of at most
129
+ * `batchTicks`, the LAST of which may be smaller (never any other position —
130
+ * the driver marks exactly the last chunk "final" and applies the caller's
131
+ * requested render mode only to it). Empty for `totalTicks <= 0`. */
132
+ export function planFastForwardBatches(totalTicks: number, batchTicks: number): number[] {
133
+ if (totalTicks <= 0) return [];
134
+ const batches: number[] = [];
135
+ let remaining = totalTicks;
136
+ while (remaining > 0) {
137
+ const chunk = Math.min(batchTicks, remaining);
138
+ batches.push(chunk);
139
+ remaining -= chunk;
140
+ }
141
+ return batches;
142
+ }
143
+
144
+ /**
145
+ * Drives a `fastForward` call against `clock`: converts `budget` to an exact
146
+ * tick count (`ticksForBudget`), batches it (`planFastForwardBatches`), runs
147
+ * each batch through `runTicksBatch` (forcing `render: 'none'` on every batch
148
+ * but the last, which gets `opts.render ?? 'last'`), heartbeats once per
149
+ * batch, and returns the final observed `{tick, simSeconds}`. See the module
150
+ * doc for why batching and the render-mode split exist.
151
+ */
152
+ export async function runFastForward(
153
+ budget: FastForwardBudget,
154
+ opts: FastForwardOptions,
155
+ clock: FastForwardClock,
156
+ ): Promise<FastForwardTime> {
157
+ const observed = await clock.readTime();
158
+ const totalTicks = ticksForBudget(budget, observed);
159
+ const batches = planFastForwardBatches(
160
+ totalTicks,
161
+ opts.batchTicks ?? DEFAULT_FAST_FORWARD_BATCH_TICKS,
162
+ );
163
+ const finalRender = opts.render ?? 'last';
164
+
165
+ let ticksDone = 0;
166
+ for (let i = 0; i < batches.length; i++) {
167
+ const size = batches[i] as number;
168
+ const isFinalBatch = i === batches.length - 1;
169
+ await clock.runTicksBatch(size, isFinalBatch ? finalRender : 'none');
170
+ ticksDone += size;
171
+ clock.heartbeat({ ticksDone, ticksTotal: totalTicks });
172
+ }
173
+
174
+ return clock.readTime();
175
+ }