@vgai/live 0.5.2 → 0.5.3
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 -0
- package/dist/editor.d.ts +78 -55
- package/dist/editor.js +129 -76
- package/dist/game-client/bridge-heartbeat.d.ts +49 -0
- package/dist/game-client/bridge-heartbeat.js +46 -0
- package/dist/game-client/bridge-transport.d.ts +75 -0
- package/dist/game-client/bridge-transport.js +19 -0
- package/dist/game-client/client.d.ts +293 -0
- package/dist/game-client/client.js +706 -0
- package/dist/game-client/errors.d.ts +57 -0
- package/dist/game-client/errors.js +76 -0
- package/dist/game-client/events-matcher.d.ts +41 -0
- package/dist/game-client/events-matcher.js +68 -0
- package/dist/game-client/failure-block.d.ts +93 -0
- package/dist/game-client/failure-block.js +97 -0
- package/dist/game-client/fast-forward.d.ts +125 -0
- package/dist/game-client/fast-forward.js +122 -0
- package/dist/game-client/hidden-recovery.d.ts +85 -0
- package/dist/game-client/hidden-recovery.js +105 -0
- package/dist/game-client/index.d.ts +40 -0
- package/dist/game-client/index.js +26 -0
- package/dist/game-client/perf-sampling.d.ts +56 -0
- package/dist/game-client/perf-sampling.js +85 -0
- package/dist/game-client/relay-transport.d.ts +100 -0
- package/dist/game-client/relay-transport.js +237 -0
- package/dist/game-client/screenshot-target.d.ts +60 -0
- package/dist/game-client/screenshot-target.js +68 -0
- package/dist/game-client/state-cap.d.ts +7 -0
- package/dist/game-client/state-cap.js +21 -0
- package/dist/game-client/types.d.ts +128 -0
- package/dist/game-client/types.js +15 -0
- package/dist/game-client/wait-for.d.ts +155 -0
- package/dist/game-client/wait-for.js +229 -0
- package/dist/game.d.ts +47 -18
- package/dist/game.js +59 -16
- package/dist/index.d.ts +46 -21
- package/dist/index.js +51 -20
- package/dist/session.d.ts +4 -4
- package/dist/session.js +7 -7
- package/dist/tools.d.ts +12 -3
- package/dist/tools.js +15 -6
- package/package.json +10 -5
- package/src/editor.ts +142 -96
- package/src/game-client/bridge-heartbeat.ts +61 -0
- package/src/game-client/bridge-transport.ts +73 -0
- package/src/game-client/client.ts +836 -0
- package/src/game-client/errors.ts +96 -0
- package/src/game-client/events-matcher.ts +106 -0
- package/src/game-client/failure-block.ts +199 -0
- package/src/game-client/fast-forward.ts +175 -0
- package/src/game-client/hidden-recovery.ts +149 -0
- package/src/game-client/index.ts +98 -0
- package/src/game-client/perf-sampling.ts +94 -0
- package/src/game-client/relay-transport.ts +311 -0
- package/src/game-client/screenshot-target.ts +91 -0
- package/src/game-client/state-cap.ts +29 -0
- package/src/game-client/types.ts +137 -0
- package/src/game-client/wait-for.ts +327 -0
- package/src/game.ts +96 -16
- package/src/index.ts +68 -31
- package/src/session.ts +8 -10
- 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
|
+
}
|