ravensight-playtest 0.1.0
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/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
// The godot_driver Driver implementation (spec 07, spec 03's Driver interface).
|
|
2
|
+
//
|
|
3
|
+
// ./driver.js is the interface: the five methods (launch, observe, act,
|
|
4
|
+
// screenshot, stop), the optional consoleErrors() and chapter(title), the
|
|
5
|
+
// Observation and Action typedefs, and the two places the drivers are allowed
|
|
6
|
+
// to differ. This class implements all of it, and `assertDriver` checks it at
|
|
7
|
+
// load time. Read that file for the contract; there is deliberately no second
|
|
8
|
+
// copy of it here, because two copies drift.
|
|
9
|
+
//
|
|
10
|
+
// Both of the differences ./driver.js names fall on this driver's side: a
|
|
11
|
+
// string `click` target is a scene tree node path, not an accessibility ref,
|
|
12
|
+
// and `stop()` answers `videoPath: null` with a `frames/` sequence beside it,
|
|
13
|
+
// because stitching is ffmpeg's job and ffmpeg stays optional.
|
|
14
|
+
//
|
|
15
|
+
// What happens at launch, in order:
|
|
16
|
+
// 1. find the developer's own Godot binary (spec 17 detection order),
|
|
17
|
+
// 2. copy the project to a temp dir and inject the driver addon there, so
|
|
18
|
+
// the real project directory is never written to,
|
|
19
|
+
// 3. spawn Godot with --ravensight-driver plus a per run loopback token, and
|
|
20
|
+
// with RAVENSIGHT_PLAYTEST_TOKEN / _RUN_ID / _JOB_ID / _PERSONA set so the
|
|
21
|
+
// game's own Ravensight SDK tags every event it tracks as synthetic,
|
|
22
|
+
// 4. dial 127.0.0.1, say hello{token}, and poll ping until the game answers.
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { randomBytes } from 'node:crypto';
|
|
25
|
+
import { createWriteStream } from 'node:fs';
|
|
26
|
+
import fs from 'node:fs/promises';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { buildObservation, needsVision } from './godot-observation.js';
|
|
30
|
+
import {
|
|
31
|
+
detectGodotBinary,
|
|
32
|
+
findFreePort,
|
|
33
|
+
packagedAddonDir,
|
|
34
|
+
prepareProjectCopy,
|
|
35
|
+
} from './godot-project.js';
|
|
36
|
+
import { DriverRpcError, GodotRpcClient } from './godot-rpc.js';
|
|
37
|
+
|
|
38
|
+
export const DRIVER_NAME = 'godot_driver';
|
|
39
|
+
export const BOOT_TIMEOUT_MS = 60000;
|
|
40
|
+
export const BOOT_POLL_MS = 250;
|
|
41
|
+
const FRAMES_PER_SECOND = 60;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The only environment variables forwarded to Godot.
|
|
45
|
+
*
|
|
46
|
+
* Godot needs a real environment to start, but it is also running the
|
|
47
|
+
* customer's game code, and that code has no business seeing the developer's
|
|
48
|
+
* ANTHROPIC_API_KEY, RAVENSIGHT_TOKEN, cloud credentials or CI secrets. So the
|
|
49
|
+
* environment is built from this list rather than inherited wholesale. Names are
|
|
50
|
+
* matched case insensitively, because Windows environment blocks are.
|
|
51
|
+
*/
|
|
52
|
+
export const ENV_ALLOWLIST = [
|
|
53
|
+
'PATH',
|
|
54
|
+
'HOME',
|
|
55
|
+
'USER',
|
|
56
|
+
'LOGNAME',
|
|
57
|
+
'SHELL',
|
|
58
|
+
'TMPDIR',
|
|
59
|
+
'TMP',
|
|
60
|
+
'TEMP',
|
|
61
|
+
'LANG',
|
|
62
|
+
'LC_ALL',
|
|
63
|
+
'LC_CTYPE',
|
|
64
|
+
'TERM',
|
|
65
|
+
'TZ',
|
|
66
|
+
// X11 and Wayland, so a Linux run can find a real or a virtual display. The
|
|
67
|
+
// last two are what makes the Xvfb path work: a software GL stack usually
|
|
68
|
+
// lives somewhere LD_LIBRARY_PATH points, and LIBGL_ALWAYS_SOFTWARE is how a
|
|
69
|
+
// headless CI box is told to use it rather than hunting for a GPU.
|
|
70
|
+
'DISPLAY',
|
|
71
|
+
'WAYLAND_DISPLAY',
|
|
72
|
+
'LD_LIBRARY_PATH',
|
|
73
|
+
'LIBGL_ALWAYS_SOFTWARE',
|
|
74
|
+
'XAUTHORITY',
|
|
75
|
+
'XDG_RUNTIME_DIR',
|
|
76
|
+
'XDG_DATA_HOME',
|
|
77
|
+
'XDG_CONFIG_HOME',
|
|
78
|
+
'XDG_CACHE_HOME',
|
|
79
|
+
'XDG_SESSION_TYPE',
|
|
80
|
+
'DBUS_SESSION_BUS_ADDRESS',
|
|
81
|
+
// Windows.
|
|
82
|
+
'SystemRoot',
|
|
83
|
+
'SystemDrive',
|
|
84
|
+
'windir',
|
|
85
|
+
'ComSpec',
|
|
86
|
+
'PATHEXT',
|
|
87
|
+
'NUMBER_OF_PROCESSORS',
|
|
88
|
+
'PROCESSOR_ARCHITECTURE',
|
|
89
|
+
'OS',
|
|
90
|
+
'USERPROFILE',
|
|
91
|
+
'USERNAME',
|
|
92
|
+
'APPDATA',
|
|
93
|
+
'LOCALAPPDATA',
|
|
94
|
+
'PROGRAMFILES',
|
|
95
|
+
'PROGRAMFILES(X86)',
|
|
96
|
+
'PROGRAMDATA',
|
|
97
|
+
'HOMEDRIVE',
|
|
98
|
+
'HOMEPATH',
|
|
99
|
+
];
|
|
100
|
+
const ERROR_LINE = /^(?:USER )?(?:SCRIPT )?ERROR:|^SCRIPT ERROR/;
|
|
101
|
+
const SOURCE_LINE = /^\s+at:\s*(.+)$/;
|
|
102
|
+
|
|
103
|
+
export class GodotDriver {
|
|
104
|
+
constructor({
|
|
105
|
+
projectDir,
|
|
106
|
+
runDir,
|
|
107
|
+
godotPath,
|
|
108
|
+
addonDir = packagedAddonDir(),
|
|
109
|
+
headless = false,
|
|
110
|
+
viewport = { w: 1280, h: 720 },
|
|
111
|
+
playtest = {},
|
|
112
|
+
driverToken,
|
|
113
|
+
port,
|
|
114
|
+
bootTimeoutMs = BOOT_TIMEOUT_MS,
|
|
115
|
+
callTimeoutMs = 15000,
|
|
116
|
+
exitTimeoutMs = 5000,
|
|
117
|
+
treeDepth = 6,
|
|
118
|
+
settleFrames = 2,
|
|
119
|
+
keepTemp = false,
|
|
120
|
+
env = process.env,
|
|
121
|
+
extraEnv = {},
|
|
122
|
+
spawnFn = spawn,
|
|
123
|
+
detect = detectGodotBinary,
|
|
124
|
+
prepare = prepareProjectCopy,
|
|
125
|
+
choosePort = findFreePort,
|
|
126
|
+
now = () => Date.now(),
|
|
127
|
+
} = {}) {
|
|
128
|
+
this.name = DRIVER_NAME;
|
|
129
|
+
this.projectDir = projectDir;
|
|
130
|
+
this.runDir = runDir;
|
|
131
|
+
this.godotPath = godotPath;
|
|
132
|
+
this.addonDir = addonDir;
|
|
133
|
+
this.headless = headless;
|
|
134
|
+
this.viewport = viewport;
|
|
135
|
+
this.playtest = playtest;
|
|
136
|
+
this.driverToken = driverToken ?? randomBytes(24).toString('hex');
|
|
137
|
+
this.port = port;
|
|
138
|
+
this.bootTimeoutMs = bootTimeoutMs;
|
|
139
|
+
this.callTimeoutMs = callTimeoutMs;
|
|
140
|
+
this.exitTimeoutMs = exitTimeoutMs;
|
|
141
|
+
this.treeDepth = treeDepth;
|
|
142
|
+
this.settleFrames = settleFrames;
|
|
143
|
+
this.keepTemp = keepTemp;
|
|
144
|
+
this.env = env;
|
|
145
|
+
this.extraEnv = extraEnv;
|
|
146
|
+
this._spawn = spawnFn;
|
|
147
|
+
this._detect = detect;
|
|
148
|
+
this._prepare = prepare;
|
|
149
|
+
this._choosePort = choosePort;
|
|
150
|
+
this._now = now;
|
|
151
|
+
|
|
152
|
+
this.rpc = null;
|
|
153
|
+
this.child = null;
|
|
154
|
+
this.godot = null;
|
|
155
|
+
this.projectCopy = null;
|
|
156
|
+
this.tempRoot = null;
|
|
157
|
+
this.logPath = null;
|
|
158
|
+
this.ping = null;
|
|
159
|
+
this.exitCode = null;
|
|
160
|
+
this.exitSignal = null;
|
|
161
|
+
this.chapters = [];
|
|
162
|
+
|
|
163
|
+
this._startedAt = null;
|
|
164
|
+
this._step = 0;
|
|
165
|
+
this._shotIndex = 0;
|
|
166
|
+
this._frameIndex = 0;
|
|
167
|
+
this._lastHash = null;
|
|
168
|
+
this._logTail = [];
|
|
169
|
+
this._logPending = '';
|
|
170
|
+
this._consoleQueue = [];
|
|
171
|
+
this._videoTimer = null;
|
|
172
|
+
this._framesDir = null;
|
|
173
|
+
this._stopped = false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** True once the game process exited on its own, which is a finding, not a driver failure. */
|
|
177
|
+
get crashed() {
|
|
178
|
+
return !this._stopped && this.exitCode !== null && this.exitCode !== 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async launch({ recordVideo = false, viewport } = {}) {
|
|
182
|
+
if (viewport) this.viewport = viewport;
|
|
183
|
+
if (!this.projectDir) throw new Error('godot driver needs a projectDir');
|
|
184
|
+
this.godot = this.godotPath
|
|
185
|
+
? await this._detect({ explicitPath: this.godotPath, env: this.env, projectDir: this.projectDir })
|
|
186
|
+
: await this._detect({ env: this.env, projectDir: this.projectDir });
|
|
187
|
+
|
|
188
|
+
const prepared = await this._prepare({
|
|
189
|
+
projectDir: this.projectDir,
|
|
190
|
+
addonDir: this.addonDir,
|
|
191
|
+
});
|
|
192
|
+
this.tempRoot = prepared.tempRoot;
|
|
193
|
+
this.projectCopy = prepared.projectCopy;
|
|
194
|
+
try {
|
|
195
|
+
this.port = this.port ?? (await this._choosePort());
|
|
196
|
+
|
|
197
|
+
if (this.runDir) await fs.mkdir(this.runDir, { recursive: true });
|
|
198
|
+
this.logPath = this.runDir ? path.join(this.runDir, 'godot.log') : null;
|
|
199
|
+
|
|
200
|
+
const args = ['--path', this.projectCopy];
|
|
201
|
+
if (this.headless) args.push('--headless');
|
|
202
|
+
else args.push('--resolution', `${this.viewport.w}x${this.viewport.h}`);
|
|
203
|
+
args.push('--', '--ravensight-driver', `--ravensight-driver-port=${this.port}`);
|
|
204
|
+
|
|
205
|
+
this.child = this._spawn(this.godot.path, args, {
|
|
206
|
+
cwd: this.projectCopy,
|
|
207
|
+
env: this.launchEnv(),
|
|
208
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
209
|
+
});
|
|
210
|
+
this._attachChild();
|
|
211
|
+
|
|
212
|
+
this.rpc = new GodotRpcClient({ port: this.port, timeoutMs: this.callTimeoutMs });
|
|
213
|
+
await this._connectWithinBootTimeout();
|
|
214
|
+
await this.rpc.call('hello', { token: this.driverToken });
|
|
215
|
+
this.ping = await this.rpc.call('ping');
|
|
216
|
+
this.headless = Boolean(this.ping.headless);
|
|
217
|
+
this._startedAt = this._now();
|
|
218
|
+
if (this.headless) {
|
|
219
|
+
this._consoleQueue.push({
|
|
220
|
+
kind: 'driver',
|
|
221
|
+
text:
|
|
222
|
+
'headless Godot has no viewport and does not route synthesized mouse or GUI input: '
|
|
223
|
+
+ 'screenshots are unavailable and clicking a Control will not press it. '
|
|
224
|
+
+ 'Run windowed for anything with a user interface.',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (recordVideo) await this._startFrameCapture();
|
|
229
|
+
return {};
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// Everything past this point has already spawned a game and copied a
|
|
232
|
+
// project, and neither tidies itself up. Without this, a project whose
|
|
233
|
+
// driver never answers leaves a stray Godot process and a temp copy behind
|
|
234
|
+
// on every attempt, and the runner retries.
|
|
235
|
+
await this.stop({ removeTemp: !this.keepTemp }).catch(() => {});
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The environment Godot is launched with. RAVENSIGHT_DRIVER* activate and
|
|
242
|
+
* authenticate the driver; the RAVENSIGHT_PLAYTEST_* set is read by the
|
|
243
|
+
* customer's own Ravensight.gd in _ready(), which is what makes every event
|
|
244
|
+
* the game tracks during this run carry synthetic / persona / pt_run tags.
|
|
245
|
+
*/
|
|
246
|
+
launchEnv() {
|
|
247
|
+
const { token, runId, jobId, persona } = this.playtest ?? {};
|
|
248
|
+
const allowed = new Set(ENV_ALLOWLIST.map((name) => name.toLowerCase()));
|
|
249
|
+
const out = {};
|
|
250
|
+
for (const [key, value] of Object.entries(this.env ?? {})) {
|
|
251
|
+
if (allowed.has(key.toLowerCase())) out[key] = value;
|
|
252
|
+
}
|
|
253
|
+
Object.assign(out, this.extraEnv ?? {});
|
|
254
|
+
Object.assign(out, {
|
|
255
|
+
RAVENSIGHT_DRIVER: '1',
|
|
256
|
+
RAVENSIGHT_DRIVER_PORT: String(this.port),
|
|
257
|
+
RAVENSIGHT_DRIVER_TOKEN: this.driverToken,
|
|
258
|
+
});
|
|
259
|
+
if (token) out.RAVENSIGHT_PLAYTEST_TOKEN = token;
|
|
260
|
+
if (runId) out.RAVENSIGHT_PLAYTEST_RUN_ID = runId;
|
|
261
|
+
if (jobId) out.RAVENSIGHT_PLAYTEST_JOB_ID = jobId;
|
|
262
|
+
if (persona) out.RAVENSIGHT_PLAYTEST_PERSONA = persona;
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async _connectWithinBootTimeout() {
|
|
267
|
+
const attempts = Math.max(1, Math.ceil(this.bootTimeoutMs / BOOT_POLL_MS));
|
|
268
|
+
try {
|
|
269
|
+
await this.rpc.connect({ attempts, delayMs: BOOT_POLL_MS });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
const tail = this._logTail.slice(-15).join('\n');
|
|
272
|
+
const exited = this.exitCode === null ? '' : ` Godot exited with code ${this.exitCode}.`;
|
|
273
|
+
throw new Error(
|
|
274
|
+
`${error.message}.${exited}${tail ? `\nLast Godot output:\n${tail}` : ''}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_attachChild() {
|
|
280
|
+
const log = this.logPath ? createWriteStream(this.logPath, { flags: 'a' }) : null;
|
|
281
|
+
for (const stream of [this.child.stdout, this.child.stderr]) {
|
|
282
|
+
if (!stream) continue;
|
|
283
|
+
stream.on('data', (chunk) => {
|
|
284
|
+
if (log) log.write(chunk);
|
|
285
|
+
this._ingestLog(chunk.toString('utf8'));
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
this.child.on('exit', (code, signal) => {
|
|
289
|
+
this.exitCode = code;
|
|
290
|
+
this.exitSignal = signal;
|
|
291
|
+
if (log) log.end();
|
|
292
|
+
});
|
|
293
|
+
this.child.on('error', (error) => {
|
|
294
|
+
this._consoleQueue.push({ kind: 'driver', text: `failed to launch Godot: ${error.message}` });
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Godot's own errors only reach us as stdout text: the engine's Logger hook
|
|
300
|
+
* is not available on every 4.x version the CLI supports, so spec 07's
|
|
301
|
+
* fallback (parse stdout) is the path taken here. An "at:" continuation line
|
|
302
|
+
* becomes the source of the error above it.
|
|
303
|
+
*/
|
|
304
|
+
_ingestLog(text) {
|
|
305
|
+
this._logPending += text;
|
|
306
|
+
const lines = this._logPending.split('\n');
|
|
307
|
+
this._logPending = lines.pop() ?? '';
|
|
308
|
+
for (const line of lines) {
|
|
309
|
+
this._logTail.push(line);
|
|
310
|
+
if (this._logTail.length > 200) this._logTail.shift();
|
|
311
|
+
const source = line.match(SOURCE_LINE);
|
|
312
|
+
if (source && this._consoleQueue.length > 0) {
|
|
313
|
+
const last = this._consoleQueue[this._consoleQueue.length - 1];
|
|
314
|
+
if (!last.source) last.source = source[1].trim();
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ERROR_LINE.test(line.trim())) {
|
|
318
|
+
this._consoleQueue.push({ kind: 'error', text: line.trim() });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async observe({ vision } = {}) {
|
|
324
|
+
this._step += 1;
|
|
325
|
+
const snapshot = await this._snapshot();
|
|
326
|
+
const { state } = snapshot;
|
|
327
|
+
let gameState = null;
|
|
328
|
+
try {
|
|
329
|
+
gameState = await this.rpc.call('get_game_state');
|
|
330
|
+
} catch (error) {
|
|
331
|
+
// A broken _ravensight_state() hook in the customer's game is a finding
|
|
332
|
+
// about their game, not a reason to end the run.
|
|
333
|
+
if (error instanceof DriverRpcError) {
|
|
334
|
+
this._consoleQueue.push({ kind: 'error', text: `get_game_state: ${error.rpcMessage}`, source: error.data?.path });
|
|
335
|
+
} else {
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
let observation = buildObservation({
|
|
340
|
+
state,
|
|
341
|
+
gameState,
|
|
342
|
+
step: this._step,
|
|
343
|
+
elapsedMs: this._startedAt === null ? 0 : this._now() - this._startedAt,
|
|
344
|
+
});
|
|
345
|
+
const wantVision = vision ?? needsVision(observation);
|
|
346
|
+
if (wantVision && !this.headless) {
|
|
347
|
+
const shot = await this._rawScreenshot();
|
|
348
|
+
if (shot) {
|
|
349
|
+
observation = { ...observation, screenshot_b64: shot.data };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// changed detection compares outline hashes only, never this observation's
|
|
353
|
+
// own hash: the observation text also carries the game state section, so
|
|
354
|
+
// comparing the two kinds of hash reported "changed" on every single act.
|
|
355
|
+
this._lastHash = snapshot.hash;
|
|
356
|
+
return observation;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async act(action) {
|
|
360
|
+
if (!action || typeof action.kind !== 'string') {
|
|
361
|
+
return { ok: false, error: 'action needs a kind', changed: false };
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
await this._perform(action);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (error instanceof DriverRpcError) {
|
|
367
|
+
return { ok: false, error: error.rpcMessage, code: error.code, data: error.data, changed: false };
|
|
368
|
+
}
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
if (action.kind === 'quit') return { ok: true, changed: false };
|
|
372
|
+
// Input reaches the game a frame after it is parsed, and a handler that
|
|
373
|
+
// changes a label runs a frame after that, so a snapshot taken the instant
|
|
374
|
+
// an action returns reports the screen as it was one action ago. Settling
|
|
375
|
+
// first is what makes "changed" mean what the persona loop thinks it means.
|
|
376
|
+
if (this.settleFrames > 0) {
|
|
377
|
+
await this.rpc.call('wait_frames', { n: this.settleFrames }).catch(() => {});
|
|
378
|
+
}
|
|
379
|
+
const { hash } = await this._snapshot();
|
|
380
|
+
const changed = this._lastHash !== null && hash !== this._lastHash;
|
|
381
|
+
this._lastHash = hash;
|
|
382
|
+
return { ok: true, changed };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async _perform(action) {
|
|
386
|
+
switch (action.kind) {
|
|
387
|
+
case 'key':
|
|
388
|
+
await this.rpc.call('key', { keycode: action.key, pressed: true });
|
|
389
|
+
await this.rpc.call('wait_frames', { n: 1 });
|
|
390
|
+
await this.rpc.call('key', { keycode: action.key, pressed: false });
|
|
391
|
+
return;
|
|
392
|
+
case 'type':
|
|
393
|
+
for (const char of String(action.text ?? '')) {
|
|
394
|
+
await this.rpc.call('key', { keycode: char, pressed: true, unicode: char.codePointAt(0) });
|
|
395
|
+
await this.rpc.call('key', { keycode: char, pressed: false, unicode: char.codePointAt(0) });
|
|
396
|
+
}
|
|
397
|
+
if (action.submit) {
|
|
398
|
+
await this.rpc.call('key', { keycode: 'Enter', pressed: true });
|
|
399
|
+
await this.rpc.call('key', { keycode: 'Enter', pressed: false });
|
|
400
|
+
}
|
|
401
|
+
await this.rpc.call('wait_frames', { n: 2 });
|
|
402
|
+
return;
|
|
403
|
+
case 'click': {
|
|
404
|
+
const target = action.target;
|
|
405
|
+
if (typeof target === 'string') {
|
|
406
|
+
await this.rpc.call('click', { node_path: target, button: action.button, double: action.double });
|
|
407
|
+
} else {
|
|
408
|
+
await this.rpc.call('click', {
|
|
409
|
+
x: target?.x ?? 0,
|
|
410
|
+
y: target?.y ?? 0,
|
|
411
|
+
button: action.button,
|
|
412
|
+
double: action.double,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
await this.rpc.call('wait_frames', { n: 2 });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
case 'press_action':
|
|
419
|
+
await this.rpc.call(
|
|
420
|
+
'press_action',
|
|
421
|
+
{ action: action.action, duration_ms: action.duration_ms ?? 50 },
|
|
422
|
+
{ timeoutMs: this.callTimeoutMs + (action.duration_ms ?? 50) },
|
|
423
|
+
);
|
|
424
|
+
return;
|
|
425
|
+
case 'wait': {
|
|
426
|
+
const frames = Math.max(1, Math.round(((action.ms ?? 0) / 1000) * FRAMES_PER_SECOND));
|
|
427
|
+
await this.rpc.call('wait_frames', { n: frames }, { timeoutMs: this.callTimeoutMs + (action.ms ?? 0) });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
case 'set_time_scale':
|
|
431
|
+
await this.rpc.call('set_time_scale', { scale: action.scale ?? 1 });
|
|
432
|
+
return;
|
|
433
|
+
case 'quit':
|
|
434
|
+
this._stopped = true;
|
|
435
|
+
await this.rpc.call('quit', { code: 0 }).catch(() => {});
|
|
436
|
+
return;
|
|
437
|
+
default:
|
|
438
|
+
throw new Error(`godot driver cannot perform action kind "${action.kind}"`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* One get_state, plus the hash of the outline it produces.
|
|
444
|
+
*
|
|
445
|
+
* Never cached. An earlier version reused the snapshot act() had just taken,
|
|
446
|
+
* to save a round trip, and that handed the persona a screen one action out of
|
|
447
|
+
* date: the point of observe() is what is on screen now.
|
|
448
|
+
*/
|
|
449
|
+
async _snapshot() {
|
|
450
|
+
const state = await this.rpc.call('get_state', { max_depth: this.treeDepth, visible_only: true });
|
|
451
|
+
const outline = buildObservation({ state, step: this._step });
|
|
452
|
+
return { state, hash: outline.hash };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async _rawScreenshot() {
|
|
456
|
+
if (this.headless) return null;
|
|
457
|
+
try {
|
|
458
|
+
return await this.rpc.call('screenshot', { max_width: this.viewport.w });
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if (error instanceof DriverRpcError) {
|
|
461
|
+
this._consoleQueue.push({ kind: 'driver', text: `screenshot: ${error.rpcMessage}` });
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Writes screenshots/NN-slug.png inside the run dir, the layout the report
|
|
470
|
+
* writer links to. Headless runs have no viewport to capture, so the call
|
|
471
|
+
* reports that rather than failing the step.
|
|
472
|
+
*/
|
|
473
|
+
async screenshot(name) {
|
|
474
|
+
if (this.headless) return { path: null, skipped: 'headless' };
|
|
475
|
+
const shot = await this._rawScreenshot();
|
|
476
|
+
if (!shot) return { path: null, skipped: 'unavailable' };
|
|
477
|
+
this._shotIndex += 1;
|
|
478
|
+
const dir = path.join(this.runDir ?? '.', 'screenshots');
|
|
479
|
+
await fs.mkdir(dir, { recursive: true });
|
|
480
|
+
const file = path.join(dir, `${String(this._shotIndex).padStart(2, '0')}-${slug(name)}.png`);
|
|
481
|
+
await fs.writeFile(file, Buffer.from(shot.data, 'base64'));
|
|
482
|
+
return { path: file, width: shot.width, height: shot.height };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Console errors since the previous call, from Godot stdout and driver events. */
|
|
486
|
+
async consoleErrors() {
|
|
487
|
+
const fromEvents = (this.rpc?.drainEvents() ?? [])
|
|
488
|
+
.filter((event) => event.type === 'error')
|
|
489
|
+
.map((event) => ({ kind: 'error', text: event.message, source: event.source, frame: event.frame }));
|
|
490
|
+
const out = [...this._consoleQueue, ...fromEvents];
|
|
491
|
+
this._consoleQueue = [];
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async chapter(title) {
|
|
496
|
+
this.chapters.push({ title, at: this._startedAt === null ? 0 : this._now() - this._startedAt });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Video on a local Godot run is a sequence of stills, not a container: the
|
|
501
|
+
* frames land in the run dir and whoever wants a webm stitches them with
|
|
502
|
+
* ffmpeg, which stays optional for the CLI.
|
|
503
|
+
*/
|
|
504
|
+
async _startFrameCapture({ fps = 4 } = {}) {
|
|
505
|
+
if (this.headless || !this.runDir) return;
|
|
506
|
+
this._framesDir = path.join(this.runDir, 'frames');
|
|
507
|
+
await fs.mkdir(this._framesDir, { recursive: true });
|
|
508
|
+
this._videoTimer = setInterval(() => {
|
|
509
|
+
void this._captureFrame();
|
|
510
|
+
}, Math.round(1000 / fps));
|
|
511
|
+
if (typeof this._videoTimer.unref === 'function') this._videoTimer.unref();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async _captureFrame() {
|
|
515
|
+
if (this._capturing || this._stopped) return;
|
|
516
|
+
this._capturing = true;
|
|
517
|
+
try {
|
|
518
|
+
const shot = await this._rawScreenshot();
|
|
519
|
+
if (!shot) return;
|
|
520
|
+
this._frameIndex += 1;
|
|
521
|
+
const file = path.join(this._framesDir, `${String(this._frameIndex).padStart(6, '0')}.png`);
|
|
522
|
+
await fs.writeFile(file, Buffer.from(shot.data, 'base64'));
|
|
523
|
+
} catch {
|
|
524
|
+
// A dropped frame is never worth interrupting a run for.
|
|
525
|
+
} finally {
|
|
526
|
+
this._capturing = false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async stop({ removeTemp = !this.keepTemp } = {}) {
|
|
531
|
+
this._stopped = true;
|
|
532
|
+
if (this._videoTimer) {
|
|
533
|
+
clearInterval(this._videoTimer);
|
|
534
|
+
this._videoTimer = null;
|
|
535
|
+
}
|
|
536
|
+
if (this.rpc && !this.rpc.closed) {
|
|
537
|
+
await this.rpc.call('quit', { code: 0 }, { timeoutMs: 2000 }).catch(() => {});
|
|
538
|
+
}
|
|
539
|
+
await this._waitForExit(this.exitTimeoutMs);
|
|
540
|
+
this.rpc?.close();
|
|
541
|
+
if (removeTemp && this.tempRoot) {
|
|
542
|
+
await fs.rm(this.tempRoot, { recursive: true, force: true }).catch(() => {});
|
|
543
|
+
this.tempRoot = null;
|
|
544
|
+
this.projectCopy = null;
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
videoPath: null,
|
|
548
|
+
framesDir: this._framesDir,
|
|
549
|
+
frameCount: this._frameIndex,
|
|
550
|
+
chapters: this.chapters,
|
|
551
|
+
logPath: this.logPath,
|
|
552
|
+
exitCode: this.exitCode,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async _waitForExit(timeoutMs) {
|
|
557
|
+
if (!this.child || this.exitCode !== null) return;
|
|
558
|
+
const exited = await new Promise((resolve) => {
|
|
559
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
560
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
561
|
+
this.child.once('exit', () => {
|
|
562
|
+
clearTimeout(timer);
|
|
563
|
+
resolve(true);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
if (exited) return;
|
|
567
|
+
try {
|
|
568
|
+
this.child.kill('SIGKILL');
|
|
569
|
+
} catch {
|
|
570
|
+
// Already gone.
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function createGodotDriver(options) {
|
|
576
|
+
return new GodotDriver(options);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function slug(name) {
|
|
580
|
+
return (
|
|
581
|
+
String(name ?? 'shot')
|
|
582
|
+
.toLowerCase()
|
|
583
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
584
|
+
.replace(/^-+|-+$/g, '')
|
|
585
|
+
.slice(0, 40) || 'shot'
|
|
586
|
+
);
|
|
587
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { assertDriver, DriverError } from './driver.js';
|
|
2
|
+
import { createWebDriver } from './web.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The driver registry. `playwright_web` is here; `godot_driver` lives in
|
|
6
|
+
* ./godot.js and is imported lazily so this package still loads on a
|
|
7
|
+
* machine with no Godot and no addon, and `cli_stdio` is not in v1.
|
|
8
|
+
*
|
|
9
|
+
* The names are the server's `PlaytestRun.driver` enum and the report
|
|
10
|
+
* schema's `driver` enum, not ours to invent.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const DRIVERS = Object.freeze(['playwright_web', 'godot_driver', 'cli_stdio']);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} name
|
|
17
|
+
* @param {Object} [options]
|
|
18
|
+
* @param {(specifier: string) => Promise<any>} [options.importer] test seam
|
|
19
|
+
* @returns {Promise<Object>} a driver satisfying ./driver.js
|
|
20
|
+
*/
|
|
21
|
+
export async function createDriver(name, options = {}) {
|
|
22
|
+
const load = options.importer || (specifier => import(specifier));
|
|
23
|
+
switch (name) {
|
|
24
|
+
case 'playwright_web':
|
|
25
|
+
return assertDriver(createWebDriver(options), name);
|
|
26
|
+
case 'godot_driver': {
|
|
27
|
+
let module;
|
|
28
|
+
try {
|
|
29
|
+
module = await load('./godot.js');
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new DriverError(
|
|
32
|
+
'The godot_driver is not available in this build of ravensight-playtest. Use --driver playwright_web against a web export, or upgrade the CLI.',
|
|
33
|
+
{ fatal: true }
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const factory = module.createGodotDriver || module.default;
|
|
37
|
+
if (typeof factory !== 'function') {
|
|
38
|
+
throw new DriverError('drivers/godot.js does not export createGodotDriver', { fatal: true });
|
|
39
|
+
}
|
|
40
|
+
return assertDriver(await factory(options), name);
|
|
41
|
+
}
|
|
42
|
+
case 'cli_stdio':
|
|
43
|
+
throw new DriverError(
|
|
44
|
+
'The cli_stdio driver is not in this release. In v1 a text build is played through its web front end with --driver playwright_web.',
|
|
45
|
+
{ fatal: true }
|
|
46
|
+
);
|
|
47
|
+
default:
|
|
48
|
+
throw new DriverError(`unknown driver "${name}"; one of: ${DRIVERS.join(', ')}`, { fatal: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default createDriver;
|