@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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #140 — drives `window.__vgai` through the editor dev-server's SESSION WIRE
|
|
3
|
+
* (`POST /__editor/command`, the same relay `vgai play`/`vgai select`/every
|
|
4
|
+
* other `EditorClient` method already uses — see `command-listener.ts`'s
|
|
5
|
+
* `bridge-call`/`bridge-screenshot` cases, the server-side half) instead of
|
|
6
|
+
* Playwright's `page.evaluate` (`client.ts`'s `PageTransport`). Used by
|
|
7
|
+
* `vgai eval`: the script drives the game INSIDE the already-open editor tab
|
|
8
|
+
* a human is watching, with zero new browser windows and zero extra vite
|
|
9
|
+
* instances.
|
|
10
|
+
*
|
|
11
|
+
* The `bridge-call` op is a session-generic primitive (see
|
|
12
|
+
* `command-listener.ts`'s `dispatchBridgeMethod` doc comment) — this
|
|
13
|
+
* transport issues one HTTP round trip per call and carries no state beyond
|
|
14
|
+
* the port, so it (or a sibling built the same way) is equally usable by a
|
|
15
|
+
* one-shot CLI/REPL call, not just a whole scripted run.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately imports nothing from `@playwright/test` — see
|
|
18
|
+
* `bridge-transport.ts`'s module doc for why that matters (no browser launch
|
|
19
|
+
* anywhere in this path).
|
|
20
|
+
*/
|
|
21
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { dirname } from 'node:path';
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
24
|
+
/** `invoke` dispatches to arbitrary game-registered debug commands — give it
|
|
25
|
+
* real headroom rather than the ordinary sync-call budget above. */
|
|
26
|
+
const INVOKE_TIMEOUT_MS = 60_000;
|
|
27
|
+
const SCREENSHOT_TIMEOUT_MS = 15_000;
|
|
28
|
+
/** A `page-script` step may itself poll (`locator.waitFor`) — give it the
|
|
29
|
+
* same headroom as `invoke` rather than the ordinary sync-call budget. */
|
|
30
|
+
const PAGE_SCRIPT_TIMEOUT_MS = 60_000;
|
|
31
|
+
/** How long one `/__editor/state` visibility sample stays good for. Short
|
|
32
|
+
* enough that foregrounding the tab mid-run is noticed almost immediately,
|
|
33
|
+
* long enough that the per-leg preflight doesn't double a bot's request
|
|
34
|
+
* count. */
|
|
35
|
+
const HIDDEN_SAMPLE_TTL_MS = 500;
|
|
36
|
+
export class RelayTransport {
|
|
37
|
+
baseUrl;
|
|
38
|
+
timeoutMs;
|
|
39
|
+
hiddenDriveLastWallMs = null;
|
|
40
|
+
hiddenDriveAnnounced = false;
|
|
41
|
+
forceHiddenDrive = false;
|
|
42
|
+
hiddenCache = null;
|
|
43
|
+
instance;
|
|
44
|
+
constructor(opts) {
|
|
45
|
+
this.baseUrl = `http://127.0.0.1:${opts.port}`;
|
|
46
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
47
|
+
this.instance = opts.instance;
|
|
48
|
+
}
|
|
49
|
+
async postCommand(body, timeoutMs) {
|
|
50
|
+
const res = await fetch(`${this.baseUrl}/__editor/command`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
55
|
+
});
|
|
56
|
+
return (await res.json());
|
|
57
|
+
}
|
|
58
|
+
/** Maps the relay's wire body back onto the transport-neutral
|
|
59
|
+
* `BridgeCallOutcome` — `result` on success, `code`/`error`/the rest of
|
|
60
|
+
* `data` on failure. This is the exact property the round-trip unit test
|
|
61
|
+
* proves: `code`/`data` survive byte-equivalent to `PageTransport`'s own
|
|
62
|
+
* `unwrap()` path. */
|
|
63
|
+
toBridgeOutcome(body) {
|
|
64
|
+
if (body.ok)
|
|
65
|
+
return { ok: true, result: body.result };
|
|
66
|
+
const { ok: _ok, error, code, result: _result, ...rest } = body;
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: {
|
|
70
|
+
code,
|
|
71
|
+
message: error ?? 'unknown relay error',
|
|
72
|
+
data: Object.keys(rest).length > 0 ? rest : undefined,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async bridgeCall(method, callArgs, timeoutMs) {
|
|
77
|
+
try {
|
|
78
|
+
// `instance` is OMITTED, not sent as undefined, when unset: the wire
|
|
79
|
+
// body is JSON, and an explicit `"instance": null` would have to be
|
|
80
|
+
// distinguished from absence on the far side for no gain.
|
|
81
|
+
const body = await this.postCommand({
|
|
82
|
+
type: 'bridge-call',
|
|
83
|
+
method,
|
|
84
|
+
callArgs,
|
|
85
|
+
...(this.instance !== undefined ? { instance: this.instance } : {}),
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
return this.toBridgeOutcome(body);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// Never hang, never throw across the transport boundary — a relay
|
|
91
|
+
// that's unreachable (no editor connected, server gone, timeout) is
|
|
92
|
+
// reported the same structured way an in-page bridge-not-installed
|
|
93
|
+
// failure is (see `client.ts`'s `bridgeCallInPage`).
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: {
|
|
97
|
+
code: 'RELAY_UNREACHABLE',
|
|
98
|
+
message: `vgai: could not reach the editor dev server relay at ${this.baseUrl} — ` +
|
|
99
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The PREFLIGHT every leg shares: sample the tab's visibility (through the
|
|
106
|
+
* short TTL cache above) and, the first time it reads hidden, say so ONCE on
|
|
107
|
+
* stdout in a stable, greppable line naming the cause and the fix.
|
|
108
|
+
*
|
|
109
|
+
* It runs on every leg, not just `call('snapshot')`, because that is where
|
|
110
|
+
* the measured gap was: a bot that drives the game with `hold`/`command` and
|
|
111
|
+
* reads through them — the shape `npm run playtest` actually has — could run
|
|
112
|
+
* its entire session against a backgrounded tab and never be told, so a
|
|
113
|
+
* later failure read as a generic relay timeout instead of "your tab is
|
|
114
|
+
* hidden". Returns whether the tab is hidden so `call` can decide whether to
|
|
115
|
+
* also drive ticks.
|
|
116
|
+
*/
|
|
117
|
+
async preflightHidden() {
|
|
118
|
+
const hidden = this.forceHiddenDrive || (await this.isHiddenCached());
|
|
119
|
+
if (hidden && !this.hiddenDriveAnnounced) {
|
|
120
|
+
this.hiddenDriveAnnounced = true;
|
|
121
|
+
process.stdout.write('vgai: the editor tab is HIDDEN — the engine hidden-pauses its loop while the tab ' +
|
|
122
|
+
'is backgrounded, so this run drives deterministic runTicks through the session relay. ' +
|
|
123
|
+
'Bring the editor tab to the foreground for real-time play.\n');
|
|
124
|
+
}
|
|
125
|
+
return hidden;
|
|
126
|
+
}
|
|
127
|
+
/** `isHidden()` is an HTTP round trip; the preflight now runs on every leg,
|
|
128
|
+
* so a short TTL keeps that from multiplying a bot's request count while
|
|
129
|
+
* still reacting to a tab the human foregrounds mid-run. */
|
|
130
|
+
async isHiddenCached() {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
if (this.hiddenCache && now - this.hiddenCache.atMs < HIDDEN_SAMPLE_TTL_MS) {
|
|
133
|
+
return this.hiddenCache.hidden;
|
|
134
|
+
}
|
|
135
|
+
const hidden = await this.isHidden();
|
|
136
|
+
this.hiddenCache = { hidden, atMs: now };
|
|
137
|
+
return hidden;
|
|
138
|
+
}
|
|
139
|
+
async call(method, callArgs) {
|
|
140
|
+
const hidden = await this.preflightHidden();
|
|
141
|
+
if (method === 'snapshot') {
|
|
142
|
+
if (hidden) {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
const elapsed = this.hiddenDriveLastWallMs === null ? 1000 / 60 : now - this.hiddenDriveLastWallMs;
|
|
145
|
+
this.hiddenDriveLastWallMs = now;
|
|
146
|
+
const ticks = Math.max(1, Math.min(30, Math.round(elapsed / (1000 / 60))));
|
|
147
|
+
const driven = await this.bridgeCall('runTicks', [ticks, { render: 'last' }], this.timeoutMs);
|
|
148
|
+
if (!driven.ok)
|
|
149
|
+
return driven;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
this.hiddenDriveLastWallMs = null;
|
|
153
|
+
this.forceHiddenDrive = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return this.bridgeCall(method, callArgs, this.timeoutMs);
|
|
157
|
+
}
|
|
158
|
+
async callAsync(method, callArgs) {
|
|
159
|
+
await this.preflightHidden();
|
|
160
|
+
return this.bridgeCall(method, callArgs, INVOKE_TIMEOUT_MS);
|
|
161
|
+
}
|
|
162
|
+
async isHidden() {
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(`${this.baseUrl}/__editor/state`, {
|
|
165
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
166
|
+
});
|
|
167
|
+
const state = (await res.json());
|
|
168
|
+
return state.presence?.visibility === 'hidden';
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A relay cannot foreground a person's browser tab. If the generic hidden
|
|
176
|
+
* recovery driver reaches this hook, force the same deterministic stepping
|
|
177
|
+
* path `call('snapshot')` normally activates from `/__editor/state`.
|
|
178
|
+
*/
|
|
179
|
+
async bringToFront() {
|
|
180
|
+
this.forceHiddenDrive = true;
|
|
181
|
+
}
|
|
182
|
+
async screenshot(path) {
|
|
183
|
+
// Same preflight as every other leg — a hidden tab's canvas is a provably
|
|
184
|
+
// stale frame, and `refreshHiddenFrame` is what asks the relay for a
|
|
185
|
+
// deterministic one-tick refresh instead of a `BRIDGE_SCREENSHOT_STALE`
|
|
186
|
+
// refusal (`command-listener.ts`'s `handleBridgeScreenshot`).
|
|
187
|
+
const body = await this.postCommand({
|
|
188
|
+
type: 'bridge-screenshot',
|
|
189
|
+
refreshHiddenFrame: await this.preflightHidden(),
|
|
190
|
+
// Address THIS transport's instance so a per-seat `game.instance(id)`
|
|
191
|
+
// screenshot captures that seat's game stack, not always the primary.
|
|
192
|
+
...(this.instance !== undefined ? { instance: this.instance } : {}),
|
|
193
|
+
}, SCREENSHOT_TIMEOUT_MS);
|
|
194
|
+
if (!body.ok || typeof body['base64'] !== 'string') {
|
|
195
|
+
throw new Error(`vgai: screenshot unavailable — ${body.error ?? 'no play-mode canvas to capture'}`);
|
|
196
|
+
}
|
|
197
|
+
await mkdir(dirname(path), { recursive: true });
|
|
198
|
+
await writeFile(path, Buffer.from(body['base64'], 'base64'));
|
|
199
|
+
// `game.screenshot()` hands back a path, so a near-blank frame is
|
|
200
|
+
// indistinguishable from a good one until somebody opens the file — which
|
|
201
|
+
// is exactly how two probes cited blank captures as evidence. The page
|
|
202
|
+
// wrote the sentence; say it where the caller is looking.
|
|
203
|
+
const warning = body['flatness']?.warning;
|
|
204
|
+
if (typeof warning === 'string')
|
|
205
|
+
console.warn(`vgai screenshot: warning — ${warning}`);
|
|
206
|
+
if (body['hiddenFrame'] === true) {
|
|
207
|
+
console.warn(`vgai screenshot: ${path} is a HIDDEN FRAME — the editor tab is hidden, so the runtime ` +
|
|
208
|
+
'rendered one deterministic tick on demand. It is current, not stale; it is not a ' +
|
|
209
|
+
'frame anyone was watching.');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Wave-2: ships `src` (`step.toString()`) to the editor dev server's
|
|
214
|
+
* `page-script` op — a STANDALONE relay command (like `bridge-screenshot`
|
|
215
|
+
* above), not a `bridge-call` method (see `command-listener.ts`'s
|
|
216
|
+
* `handlePageScript` doc comment for why). `step` itself is unused on this
|
|
217
|
+
* leg — closures don't survive the wire, see `bridge-transport.ts`'s
|
|
218
|
+
* `runPageScript` doc comment — kept only to satisfy the shared interface
|
|
219
|
+
* `PageTransport` (which DOES call it directly) also implements.
|
|
220
|
+
*/
|
|
221
|
+
async runPageScript(src, _step) {
|
|
222
|
+
try {
|
|
223
|
+
const body = await this.postCommand({ type: 'page-script', src }, PAGE_SCRIPT_TIMEOUT_MS);
|
|
224
|
+
return this.toBridgeOutcome(body);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
error: {
|
|
230
|
+
code: 'RELAY_UNREACHABLE',
|
|
231
|
+
message: `vgai: could not reach the editor dev server relay at ${this.baseUrl} — ` +
|
|
232
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `GameClient.screenshot(x)` actually writes.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS ITS OWN MODULE (measured 2026-08-02, live session):
|
|
5
|
+
* `game.screenshot('.vgai/tmp/dragon/play-live.png')` — driven through
|
|
6
|
+
* `vgai eval`, the documented general door onto a running game — reported
|
|
7
|
+
* success and left NOTHING at the path the caller named. The argument was
|
|
8
|
+
* being read as a LABEL and run through `sanitizeLabel`, so the bytes landed
|
|
9
|
+
* at `<project>/.vgai/last-run/001--vgai-tmp-dragon-play-live-png.png`.
|
|
10
|
+
* Both artifacts are still on disk in the reproduction project. A call that
|
|
11
|
+
* reports success while the file the caller asked for does not exist is
|
|
12
|
+
* fabricated evidence — the exact failure mode `vgai screenshot` was built to
|
|
13
|
+
* prevent, reintroduced one layer down.
|
|
14
|
+
*
|
|
15
|
+
* The fix is to honour what the caller wrote. A LABEL ("waitfor-timeout") is
|
|
16
|
+
* a bare identifier: it keeps the numbered-artifact behaviour every run
|
|
17
|
+
* depends on. A PATH (anything with a separator, or any name carrying a file
|
|
18
|
+
* extension) is a destination: it is written EXACTLY there, relative paths
|
|
19
|
+
* resolved against the process cwd — the same rule `vgai screenshot --out`
|
|
20
|
+
* already documents.
|
|
21
|
+
*/
|
|
22
|
+
/** A caller's argument, classified. */
|
|
23
|
+
export type ScreenshotArgKind = 'path' | 'label';
|
|
24
|
+
/**
|
|
25
|
+
* PATH when the argument names a location: absolute, containing a `/` or `\`
|
|
26
|
+
* separator, an explicit `./`-style relative prefix, or carrying a file
|
|
27
|
+
* extension (`shot.png`). LABEL otherwise — the bare-identifier form specs
|
|
28
|
+
* pass (`'waitfor-timeout'`, `'events-expect-failure'`).
|
|
29
|
+
*
|
|
30
|
+
* The extension rule is what makes `screenshot('frame.png')` land at
|
|
31
|
+
* `./frame.png` instead of `.../001-frame-png.png`: a caller who typed an
|
|
32
|
+
* extension asked for a file, not a caption.
|
|
33
|
+
*/
|
|
34
|
+
export declare function classifyScreenshotArg(arg: string): ScreenshotArgKind;
|
|
35
|
+
/** Non-filename characters collapse to `-` for the label form. */
|
|
36
|
+
export declare function sanitizeScreenshotLabel(label: string): string;
|
|
37
|
+
export interface ScreenshotTargetInput {
|
|
38
|
+
/** The caller's argument — a label or a path (see {@link classifyScreenshotArg}). */
|
|
39
|
+
readonly arg: string;
|
|
40
|
+
/** Artifacts directory for the label form. */
|
|
41
|
+
readonly artifactsDir: string;
|
|
42
|
+
/** 1-based ordinal for the label form's `NNN-` prefix. */
|
|
43
|
+
readonly sequence: number;
|
|
44
|
+
/** Base for resolving a relative path/artifactsDir — the process cwd. */
|
|
45
|
+
readonly cwd: string;
|
|
46
|
+
}
|
|
47
|
+
export interface ScreenshotTarget {
|
|
48
|
+
readonly kind: ScreenshotArgKind;
|
|
49
|
+
/** Absolute destination. */
|
|
50
|
+
readonly path: string;
|
|
51
|
+
/** True when the caller's own ordinal was consumed (label form only), so a
|
|
52
|
+
* path-form call never perturbs the numbering of the artifacts around it. */
|
|
53
|
+
readonly consumedSequence: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the absolute file a screenshot call must write. Pure — no I/O, no
|
|
57
|
+
* `process.cwd()` read — so the contract above is testable without a browser,
|
|
58
|
+
* a session, or a filesystem.
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveScreenshotTarget(input: ScreenshotTargetInput): ScreenshotTarget;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `GameClient.screenshot(x)` actually writes.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS ITS OWN MODULE (measured 2026-08-02, live session):
|
|
5
|
+
* `game.screenshot('.vgai/tmp/dragon/play-live.png')` — driven through
|
|
6
|
+
* `vgai eval`, the documented general door onto a running game — reported
|
|
7
|
+
* success and left NOTHING at the path the caller named. The argument was
|
|
8
|
+
* being read as a LABEL and run through `sanitizeLabel`, so the bytes landed
|
|
9
|
+
* at `<project>/.vgai/last-run/001--vgai-tmp-dragon-play-live-png.png`.
|
|
10
|
+
* Both artifacts are still on disk in the reproduction project. A call that
|
|
11
|
+
* reports success while the file the caller asked for does not exist is
|
|
12
|
+
* fabricated evidence — the exact failure mode `vgai screenshot` was built to
|
|
13
|
+
* prevent, reintroduced one layer down.
|
|
14
|
+
*
|
|
15
|
+
* The fix is to honour what the caller wrote. A LABEL ("waitfor-timeout") is
|
|
16
|
+
* a bare identifier: it keeps the numbered-artifact behaviour every run
|
|
17
|
+
* depends on. A PATH (anything with a separator, or any name carrying a file
|
|
18
|
+
* extension) is a destination: it is written EXACTLY there, relative paths
|
|
19
|
+
* resolved against the process cwd — the same rule `vgai screenshot --out`
|
|
20
|
+
* already documents.
|
|
21
|
+
*/
|
|
22
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
23
|
+
/**
|
|
24
|
+
* PATH when the argument names a location: absolute, containing a `/` or `\`
|
|
25
|
+
* separator, an explicit `./`-style relative prefix, or carrying a file
|
|
26
|
+
* extension (`shot.png`). LABEL otherwise — the bare-identifier form specs
|
|
27
|
+
* pass (`'waitfor-timeout'`, `'events-expect-failure'`).
|
|
28
|
+
*
|
|
29
|
+
* The extension rule is what makes `screenshot('frame.png')` land at
|
|
30
|
+
* `./frame.png` instead of `.../001-frame-png.png`: a caller who typed an
|
|
31
|
+
* extension asked for a file, not a caption.
|
|
32
|
+
*/
|
|
33
|
+
export function classifyScreenshotArg(arg) {
|
|
34
|
+
if (arg === '')
|
|
35
|
+
return 'label';
|
|
36
|
+
if (isAbsolute(arg))
|
|
37
|
+
return 'path';
|
|
38
|
+
if (arg.includes('/') || arg.includes('\\'))
|
|
39
|
+
return 'path';
|
|
40
|
+
if (/\.[a-zA-Z0-9]{1,8}$/.test(arg))
|
|
41
|
+
return 'path';
|
|
42
|
+
return 'label';
|
|
43
|
+
}
|
|
44
|
+
/** Non-filename characters collapse to `-` for the label form. */
|
|
45
|
+
export function sanitizeScreenshotLabel(label) {
|
|
46
|
+
return label.replace(/[^a-zA-Z0-9-_]+/g, '-');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the absolute file a screenshot call must write. Pure — no I/O, no
|
|
50
|
+
* `process.cwd()` read — so the contract above is testable without a browser,
|
|
51
|
+
* a session, or a filesystem.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveScreenshotTarget(input) {
|
|
54
|
+
const kind = classifyScreenshotArg(input.arg);
|
|
55
|
+
if (kind === 'path') {
|
|
56
|
+
const withExtension = /\.[a-zA-Z0-9]{1,8}$/.test(input.arg) ? input.arg : `${input.arg}.png`;
|
|
57
|
+
return {
|
|
58
|
+
kind,
|
|
59
|
+
path: isAbsolute(withExtension) ? withExtension : resolve(input.cwd, withExtension),
|
|
60
|
+
consumedSequence: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const fileName = `${String(input.sequence).padStart(3, '0')}-${sanitizeScreenshotLabel(input.arg)}.png`;
|
|
64
|
+
const dir = isAbsolute(input.artifactsDir)
|
|
65
|
+
? input.artifactsDir
|
|
66
|
+
: resolve(input.cwd, input.artifactsDir);
|
|
67
|
+
return { kind, path: resolve(dir, fileName), consumedSequence: true };
|
|
68
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Byte-capping for the failure block's "last state" member (Task 3.3: never
|
|
2
|
+
* elided, byte-capped at 4KB, truncation always marked). */
|
|
3
|
+
export interface CappedJson {
|
|
4
|
+
text: string;
|
|
5
|
+
truncated: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function capJson(value: unknown, maxBytes?: number): CappedJson;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Byte-capping for the failure block's "last state" member (Task 3.3: never
|
|
2
|
+
* elided, byte-capped at 4KB, truncation always marked). */
|
|
3
|
+
const TRUNCATION_MARKER = '\n… [truncated to fit the 4KB cap]';
|
|
4
|
+
export function capJson(value, maxBytes = 4096) {
|
|
5
|
+
const full = JSON.stringify(value, null, 2) ?? 'undefined';
|
|
6
|
+
if (byteLength(full) <= maxBytes) {
|
|
7
|
+
return { text: full, truncated: false };
|
|
8
|
+
}
|
|
9
|
+
const budget = Math.max(maxBytes - byteLength(TRUNCATION_MARKER), 0);
|
|
10
|
+
let text = full;
|
|
11
|
+
// Binary-search-free shrink: strings only grow bytes via multi-byte UTF-8,
|
|
12
|
+
// so a length-proportional slice converges in a handful of iterations.
|
|
13
|
+
while (byteLength(text) > budget && text.length > 0) {
|
|
14
|
+
const ratio = budget / byteLength(text);
|
|
15
|
+
text = text.slice(0, Math.max(Math.floor(text.length * ratio), 0));
|
|
16
|
+
}
|
|
17
|
+
return { text: text + TRUNCATION_MARKER, truncated: true };
|
|
18
|
+
}
|
|
19
|
+
function byteLength(text) {
|
|
20
|
+
return new TextEncoder().encode(text).length;
|
|
21
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frozen shape of `window.__vgai`, the in-page debug bridge installed by
|
|
3
|
+
* the engine (`packages/engine/src/runtime/debug-bridge.ts`, Task 2.1 —
|
|
4
|
+
* landing concurrently with this package). This module declares that shape
|
|
5
|
+
* independently; it never imports the engine package, so this package can be
|
|
6
|
+
* built and tested independently of the bridge's landing.
|
|
7
|
+
*/
|
|
8
|
+
export type ValueTier = 'observable' | 'assisted';
|
|
9
|
+
export interface ProviderInfo {
|
|
10
|
+
name: string;
|
|
11
|
+
tier: ValueTier;
|
|
12
|
+
}
|
|
13
|
+
export interface DebugCommandInfo {
|
|
14
|
+
name: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
argsJsonSchema?: unknown;
|
|
17
|
+
locus: 'client' | 'server';
|
|
18
|
+
}
|
|
19
|
+
/** Run-4 friction #5: `seq` is a registry-lifetime monotonic counter (never
|
|
20
|
+
* reset, never shared by two events — unlike `tick`, which a debug-command
|
|
21
|
+
* emission and a fenced consumer's snapshot can legitimately collide on).
|
|
22
|
+
* Mirrors `@vgai/engine`'s `TickStampedEvent` (`adapter/system-adapter.ts`)
|
|
23
|
+
* — this package never imports the engine (see the module doc above), so
|
|
24
|
+
* the shape is declared here from the same contract. */
|
|
25
|
+
export interface TickStampedEvent {
|
|
26
|
+
tick: number;
|
|
27
|
+
simT: number;
|
|
28
|
+
event: string;
|
|
29
|
+
detail?: unknown;
|
|
30
|
+
seq: number;
|
|
31
|
+
}
|
|
32
|
+
export type VirtualActionValue = boolean | number | {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
};
|
|
36
|
+
export interface VirtualActionResult {
|
|
37
|
+
delivered: boolean;
|
|
38
|
+
reason?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface DebugBridgeInput {
|
|
41
|
+
setVirtualAction(action: string, value: VirtualActionValue): VirtualActionResult;
|
|
42
|
+
tapVirtualAction(action: string): VirtualActionResult;
|
|
43
|
+
clearVirtualActions(): void;
|
|
44
|
+
/** D15/T-D15.5 — schedule a virtual actuation for a specific tick, applied
|
|
45
|
+
* at the start of that tick's input phase. Declared here (Wave-2
|
|
46
|
+
* bridge↔wire coverage-parity gate) for type-shape completeness with
|
|
47
|
+
* `runtime/debug-bridge.ts`'s `VgaiDebugInputHandle` — this package still
|
|
48
|
+
* exposes no client-side convenience wrapper around it (deliberately
|
|
49
|
+
* parked; see `GameInput` in `client.ts`), this is pure type-shape
|
|
50
|
+
* mirroring. */
|
|
51
|
+
scheduleActionAtTick(tick: number, action: string, value: VirtualActionValue): void;
|
|
52
|
+
/** Wave-2 pointer-dispatch op — mirrors `runtime/debug-bridge.ts`'s
|
|
53
|
+
* `VgaiDebugInputHandle.injectPointerDelta`: accumulates a synthetic
|
|
54
|
+
* pointer delta for a named test source (sums within a frame, clears each
|
|
55
|
+
* frame). Declared here for type-shape completeness with the bridge, same
|
|
56
|
+
* precedent as `scheduleActionAtTick` above — no client-side convenience
|
|
57
|
+
* wrapper in `client.ts` (deliberately parked). */
|
|
58
|
+
injectPointerDelta(sourceId: string, delta: {
|
|
59
|
+
x: number;
|
|
60
|
+
y: number;
|
|
61
|
+
}): void;
|
|
62
|
+
/** Wave-2 pointer-dispatch op — mirrors `runtime/debug-bridge.ts`'s
|
|
63
|
+
* `VgaiDebugInputHandle.injectPointerPosition`: sets a synthetic absolute
|
|
64
|
+
* pointer position for a named test source (last-write-wins, persists
|
|
65
|
+
* until changed). Same type-shape-only precedent as `scheduleActionAtTick`. */
|
|
66
|
+
injectPointerPosition(sourceId: string, value: {
|
|
67
|
+
x: number;
|
|
68
|
+
y: number;
|
|
69
|
+
}): void;
|
|
70
|
+
}
|
|
71
|
+
/** D15/T-D15.4 door (a) — `Game.runTicks`'s options, mirrored from the
|
|
72
|
+
* engine's own `runtime/debug-registry.ts` `RunTicksOptions` (this package
|
|
73
|
+
* never imports the engine — see the module doc above — so the shape is
|
|
74
|
+
* declared here from the build plan's/D15 doc's contract text, same as
|
|
75
|
+
* every other bridge member). */
|
|
76
|
+
export interface RunTicksOptions {
|
|
77
|
+
render?: 'last' | 'all' | 'none';
|
|
78
|
+
}
|
|
79
|
+
/** One coherent read of the whole bridge — `snapshot()` is the fixture's poll
|
|
80
|
+
* primitive: every field comes from the same synchronous pass. */
|
|
81
|
+
export interface DebugSnapshot {
|
|
82
|
+
time: {
|
|
83
|
+
simSeconds: number;
|
|
84
|
+
tick: number;
|
|
85
|
+
/**
|
|
86
|
+
* Issue #175 — the REAL engine `GameLoop.liveness` behind this session
|
|
87
|
+
* (mirrors `@vgai/engine`'s `GameLoopLiveness`; this package never
|
|
88
|
+
* imports the engine — see the module doc above — so the union is
|
|
89
|
+
* declared here from the same contract). `'hidden-paused'` means the
|
|
90
|
+
* T2.1 idle throttle has stopped the loop because the tab is
|
|
91
|
+
* backgrounded: `simSeconds`/`tick` above are frozen, but this is NOT a
|
|
92
|
+
* crashed/wedged game — it resumes the instant the tab is foregrounded.
|
|
93
|
+
* `undefined` against an older bridge build that predates this field;
|
|
94
|
+
* `null` when the live bridge has no loop wired at all (should not
|
|
95
|
+
* happen against a real `Game`, but never fabricated either way).
|
|
96
|
+
*/
|
|
97
|
+
loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null;
|
|
98
|
+
};
|
|
99
|
+
state: Record<string, unknown>;
|
|
100
|
+
events: TickStampedEvent[];
|
|
101
|
+
pageErrors: string[];
|
|
102
|
+
}
|
|
103
|
+
/** `window.__vgai`'s shape (version 1, frozen — see the build plan's ground
|
|
104
|
+
* rule 4). */
|
|
105
|
+
export interface VgaiBridgeHandle {
|
|
106
|
+
version: 1;
|
|
107
|
+
providers(): ProviderInfo[];
|
|
108
|
+
state(name: string): unknown;
|
|
109
|
+
stateAll(): Record<string, unknown>;
|
|
110
|
+
commands(): DebugCommandInfo[];
|
|
111
|
+
invoke(name: string, args: unknown[]): Promise<unknown>;
|
|
112
|
+
/** Run-4 friction #5: `sinceSeq`, when given, fences on
|
|
113
|
+
* `TickStampedEvent.seq` (unambiguous — see that field's doc comment). The
|
|
114
|
+
* old `sinceTick` fence (`tick > sinceTick`, which dropped any event
|
|
115
|
+
* sharing the fence's own tick) was REMOVED. */
|
|
116
|
+
events(sinceSeq?: number): TickStampedEvent[];
|
|
117
|
+
input: DebugBridgeInput;
|
|
118
|
+
snapshot(sinceSeq?: number): DebugSnapshot;
|
|
119
|
+
/** D15/T-D15.4 — see `RunTicksOptions`'s doc comment above. */
|
|
120
|
+
runTicks(n: number, opts?: RunTicksOptions): void;
|
|
121
|
+
/** Collapses `input.setVirtualAction(action, true)` → wait `simSeconds` of
|
|
122
|
+
* sim time (host-loop ticks while visible, deterministic same-phase ticks
|
|
123
|
+
* while hidden-paused) → `input.clearVirtualActions()` into one call — see
|
|
124
|
+
* `runtime/debug-bridge.ts`'s
|
|
125
|
+
* `holdFor` doc comment for the full contract (gated-immediately /
|
|
126
|
+
* play-stopped-mid-wait shapes). */
|
|
127
|
+
holdFor(action: string, simSeconds: number, worldId?: string): Promise<VirtualActionResult>;
|
|
128
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frozen shape of `window.__vgai`, the in-page debug bridge installed by
|
|
3
|
+
* the engine (`packages/engine/src/runtime/debug-bridge.ts`, Task 2.1 —
|
|
4
|
+
* landing concurrently with this package). This module declares that shape
|
|
5
|
+
* independently; it never imports the engine package, so this package can be
|
|
6
|
+
* built and tested independently of the bridge's landing.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
9
|
+
// Deliberately no `declare global { interface Window { __vgai } }` here:
|
|
10
|
+
// the engine's own debug-bridge module (landing concurrently) is the real
|
|
11
|
+
// installer and may declare its own global augmentation for `window.__vgai`.
|
|
12
|
+
// Two independent ambient declarations of the same global member are only
|
|
13
|
+
// safe if structurally identical, and this package must not assume that —
|
|
14
|
+
// every access reaches through an explicit `window as { __vgai?: ... }` cast
|
|
15
|
+
// at the `page.evaluate()` boundary instead (see client.ts).
|