ravensight-playtest 0.5.2 → 0.7.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/README.md +319 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +120 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +233 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +647 -70
- package/src/commands/init.js +19 -3
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +25 -6
- package/src/commands/punch.js +116 -0
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/tape.js +74 -0
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- package/templates/godot-3d/tools/selftest.gd +77 -0
package/src/run/drivers/godot.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// game's own Ravensight SDK tags every event it tracks as synthetic,
|
|
22
22
|
// 4. dial 127.0.0.1, say hello{token}, and poll ping until the game answers.
|
|
23
23
|
import { spawn } from 'node:child_process';
|
|
24
|
+
import { track } from '../liveWork.js';
|
|
24
25
|
import { randomBytes } from 'node:crypto';
|
|
25
26
|
import { createWriteStream } from 'node:fs';
|
|
26
27
|
import fs from 'node:fs/promises';
|
|
@@ -29,18 +30,103 @@ import path from 'node:path';
|
|
|
29
30
|
import { buildObservation, needsVision } from './godot-observation.js';
|
|
30
31
|
import {
|
|
31
32
|
detectGodotBinary,
|
|
32
|
-
|
|
33
|
+
reservePort,
|
|
34
|
+
importProjectCopy,
|
|
33
35
|
packagedAddonDir,
|
|
34
36
|
prepareProjectCopy,
|
|
37
|
+
projectNeedsImport,
|
|
38
|
+
setIniEntry,
|
|
35
39
|
} from './godot-project.js';
|
|
40
|
+
import { screenshotName } from './driver.js';
|
|
36
41
|
import { DriverRpcError, GodotRpcClient } from './godot-rpc.js';
|
|
37
42
|
import { detectFfmpeg } from '../../detect.js';
|
|
38
43
|
import { recordingLimits, recordingViewport, recordingDirectory, recordingMetadata, saveRecording, encodeFrames } from './recording.js';
|
|
44
|
+
import { createTraceWriter, seedFrom, TRACE_FILE } from '../trace.js';
|
|
39
45
|
|
|
40
46
|
export const DRIVER_NAME = 'godot_driver';
|
|
41
47
|
export const BOOT_TIMEOUT_MS = 60000;
|
|
42
48
|
export const BOOT_POLL_MS = 250;
|
|
43
|
-
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The longest one key action may hold its keys, in milliseconds. The addon's
|
|
52
|
+
* own ceiling for a held input action is 10 s; a persona turn holding the
|
|
53
|
+
* game for longer than this is not playing it, it is waiting.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_HOLD_MS = 5000;
|
|
56
|
+
|
|
57
|
+
/** The longest wait action, matching the browser driver's. */
|
|
58
|
+
export const MAX_WAIT_MS = 10000;
|
|
59
|
+
|
|
60
|
+
/** The most keys one action may hold down together (a diagonal is two). */
|
|
61
|
+
export const MAX_HELD_KEYS = 3;
|
|
62
|
+
|
|
63
|
+
/** In the path of every user:// a lockstep run creates, so stop removes only those. */
|
|
64
|
+
const USER_DIR_MARKER = 'ravensight-lockstep';
|
|
65
|
+
|
|
66
|
+
/** The longest one lockstep step may run, matching the addon's own ceiling. */
|
|
67
|
+
export const MAX_STEP_TICKS = 3600;
|
|
68
|
+
|
|
69
|
+
/** How much longer a step's answer may take per tick, on top of the call timeout. */
|
|
70
|
+
const STEP_MS_PER_TICK = 34;
|
|
71
|
+
|
|
72
|
+
/** Frames the game is given after ping before its boot is judged. */
|
|
73
|
+
export const BOOT_SETTLE_FRAMES = 10;
|
|
74
|
+
|
|
75
|
+
/** The error code, and the run's stateReason prefix, for a game that never booted. */
|
|
76
|
+
export const BOOT_FAILED = 'game_boot_failed';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lines in Godot's own output that mean the game's scripts or main scene did
|
|
80
|
+
* not load. Seen before the first observation, the persona would be playing a
|
|
81
|
+
* black screen, so the launch fails instead.
|
|
82
|
+
*/
|
|
83
|
+
const BOOT_FATAL_LINE = [
|
|
84
|
+
/Parse Error:/i,
|
|
85
|
+
/Failed to load script ".*" with error/i,
|
|
86
|
+
/Failed loading resource: res:\/\/.*\.(?:tscn|scn)/i,
|
|
87
|
+
/(?:Can't|Couldn't|Failed to) (?:load|open) (?:the )?main scene/i,
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** The tick rate Godot assumes when a project does not state its own. */
|
|
91
|
+
export const DEFAULT_TICKS_PER_SECOND = 60;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The tick rate a lockstep run uses: the power of two nearest the project's own.
|
|
95
|
+
*
|
|
96
|
+
* Not the project's own rate, and this is measured rather than tidy. Godot decides
|
|
97
|
+
* how many physics steps a frame gets by adding 1/fps to a float and flooring it
|
|
98
|
+
* times the tick rate. At 60 that float is inexact, so now and then a frame gets
|
|
99
|
+
* none and the next gets two, at a moment that depends on how many frames passed
|
|
100
|
+
* while parked, which is the wall clock's choice: a hold came out a tick long or
|
|
101
|
+
* short, at random, in about half of windowed replays. 1/64 is exact, so every
|
|
102
|
+
* frame is one tick. The game runs a few percent off its usual physics rate; its
|
|
103
|
+
* delta says so, and `game_seconds` is still real game time.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} projectDir
|
|
106
|
+
* @returns {Promise<number>}
|
|
107
|
+
*/
|
|
108
|
+
export async function projectTicksPerSecond(projectDir) {
|
|
109
|
+
const text = await fs.readFile(path.join(projectDir, 'project.godot'), 'utf8').catch(() => '');
|
|
110
|
+
const match = text.match(/^\s*common\/physics_ticks_per_second\s*=\s*(\d+)/m);
|
|
111
|
+
const declared = match ? Number(match[1]) : DEFAULT_TICKS_PER_SECOND;
|
|
112
|
+
const ticks = declared >= 1 && declared <= 1000 ? declared : DEFAULT_TICKS_PER_SECOND;
|
|
113
|
+
return 2 ** Math.round(Math.log2(ticks));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* How long, on the wall clock, a hold of `holdMs` of game time takes at the
|
|
118
|
+
* game's time scale. Counted on the clock rather than in frames: live on a
|
|
119
|
+
* 120 Hz display, 120 frames was one game second, so a hold counted at 60
|
|
120
|
+
* frames a second came out half as long as asked.
|
|
121
|
+
*
|
|
122
|
+
* @param {number} holdMs
|
|
123
|
+
* @param {number} scale the game's time scale while the hold runs
|
|
124
|
+
* @returns {number}
|
|
125
|
+
*/
|
|
126
|
+
export function holdWallMs(holdMs, scale = 1) {
|
|
127
|
+
const ms = Math.min(Math.max(Number(holdMs) || 0, 0), MAX_HOLD_MS);
|
|
128
|
+
return Number(scale) > 0 ? Math.round(ms / Number(scale)) : ms;
|
|
129
|
+
}
|
|
44
130
|
|
|
45
131
|
/**
|
|
46
132
|
* The only environment variables forwarded to Godot.
|
|
@@ -102,6 +188,29 @@ export const ENV_ALLOWLIST = [
|
|
|
102
188
|
const ERROR_LINE = /^(?:USER )?(?:SCRIPT )?ERROR:|^SCRIPT ERROR/;
|
|
103
189
|
const SOURCE_LINE = /^\s+at:\s*(.+)$/;
|
|
104
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The events as a replay should send them. A click named by node path goes in the
|
|
193
|
+
* trace as where it landed: a generated name such as @CanvasLayer@10 carries a
|
|
194
|
+
* process-wide counter, so the same path can be gone on the next run, or on a commit
|
|
195
|
+
* that only added a node somewhere else. A button that really moved misses, and the
|
|
196
|
+
* state says so.
|
|
197
|
+
*
|
|
198
|
+
* @param {Array<Object>} events
|
|
199
|
+
* @param {Array<Array<number>>} [clicks] - where the driver says each click landed
|
|
200
|
+
* @returns {Array<Object>}
|
|
201
|
+
*/
|
|
202
|
+
export function traceableEvents(events, clicks = []) {
|
|
203
|
+
let index = 0;
|
|
204
|
+
return events.map((event) => {
|
|
205
|
+
if (event.type !== 'click') return event;
|
|
206
|
+
const at = clicks[index];
|
|
207
|
+
index += 1;
|
|
208
|
+
if (!at) return event;
|
|
209
|
+
const { node_path: ignored, ...rest } = event;
|
|
210
|
+
return { ...rest, x: at[0], y: at[1] };
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
105
214
|
export class GodotDriver {
|
|
106
215
|
constructor({
|
|
107
216
|
projectDir,
|
|
@@ -119,18 +228,35 @@ export class GodotDriver {
|
|
|
119
228
|
treeDepth = 6,
|
|
120
229
|
settleFrames = 2,
|
|
121
230
|
keepTemp = false,
|
|
231
|
+
// The game's clock is frozen between actions (time_scale 0) and runs only
|
|
232
|
+
// while an action executes, so a persona is never punished for thinking.
|
|
233
|
+
// Off for a game that has to keep running in real time.
|
|
234
|
+
pauseBetweenActions = true,
|
|
235
|
+
// Deterministic play: a stopped clock from before the first scene, a seed,
|
|
236
|
+
// fixed ticks, and every input recorded to trace.jsonl. Run paths turn it on
|
|
237
|
+
// (drivers: `driverOptions`); it needs the clock parked between actions.
|
|
238
|
+
lockstep = false,
|
|
239
|
+
seed,
|
|
240
|
+
recordTrace = true,
|
|
241
|
+
traceHeader = {},
|
|
242
|
+
importTimeoutMs = 180000,
|
|
243
|
+
needsImport = projectNeedsImport,
|
|
244
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
245
|
+
importProject = importProjectCopy,
|
|
122
246
|
env = process.env,
|
|
123
247
|
extraEnv = {},
|
|
124
248
|
spawnFn = spawn,
|
|
125
249
|
detect = detectGodotBinary,
|
|
126
250
|
prepare = prepareProjectCopy,
|
|
127
|
-
choosePort =
|
|
251
|
+
choosePort = reservePort,
|
|
128
252
|
now = () => Date.now(),
|
|
129
253
|
recording = {},
|
|
130
254
|
detectEncoder = detectFfmpeg,
|
|
131
255
|
encode = encodeFrames,
|
|
132
256
|
} = {}) {
|
|
133
257
|
this.name = DRIVER_NAME;
|
|
258
|
+
// The key action holds its keys for hold_ms (and with_keys together).
|
|
259
|
+
this.supportsHold = true;
|
|
134
260
|
this.projectDir = projectDir;
|
|
135
261
|
this.runDir = runDir;
|
|
136
262
|
this.godotPath = godotPath;
|
|
@@ -146,6 +272,26 @@ export class GodotDriver {
|
|
|
146
272
|
this.treeDepth = treeDepth;
|
|
147
273
|
this.settleFrames = settleFrames;
|
|
148
274
|
this.keepTemp = keepTemp;
|
|
275
|
+
this.pauseBetweenActions = pauseBetweenActions;
|
|
276
|
+
this.lockstep = Boolean(lockstep);
|
|
277
|
+
this.seed = Number.isInteger(seed) ? seed : seedFrom(playtest?.runId ?? 'ravensight');
|
|
278
|
+
this.recordTrace = recordTrace;
|
|
279
|
+
this.traceHeader = traceHeader;
|
|
280
|
+
this.ticksPerSecond = DEFAULT_TICKS_PER_SECOND;
|
|
281
|
+
this.tick = 0;
|
|
282
|
+
this.lastDigest = null;
|
|
283
|
+
this.bootDigest = null;
|
|
284
|
+
this.deaths = null;
|
|
285
|
+
this.errorCount = 0;
|
|
286
|
+
this.reasserted = 0;
|
|
287
|
+
this.trace = null;
|
|
288
|
+
this.traceSecrets = [];
|
|
289
|
+
this.importTimeoutMs = importTimeoutMs;
|
|
290
|
+
this._needsImport = needsImport;
|
|
291
|
+
this._sleep = sleep;
|
|
292
|
+
this._importProject = importProject;
|
|
293
|
+
this._resumeScale = 1;
|
|
294
|
+
this._paused = false;
|
|
149
295
|
this.env = env;
|
|
150
296
|
this.extraEnv = extraEnv;
|
|
151
297
|
this._spawn = spawnFn;
|
|
@@ -206,13 +352,28 @@ export class GodotDriver {
|
|
|
206
352
|
});
|
|
207
353
|
this.tempRoot = prepared.tempRoot;
|
|
208
354
|
this.projectCopy = prepared.projectCopy;
|
|
355
|
+
// A second Ctrl-C runs no finally: this is what still removes the copy and the game.
|
|
356
|
+
this._untrack = track({
|
|
357
|
+
kill: () => this.child?.kill('SIGKILL'),
|
|
358
|
+
root: () => (this.keepTemp ? [] : [this.tempRoot, this.userDataDir?.includes(USER_DIR_MARKER) ? this.userDataDir : null])
|
|
359
|
+
});
|
|
209
360
|
try {
|
|
210
361
|
this.port = this.port ?? (await this._choosePort());
|
|
211
362
|
|
|
212
363
|
if (this.runDir) await fs.mkdir(this.runDir, { recursive: true });
|
|
213
364
|
this.logPath = this.runDir ? path.join(this.runDir, 'godot.log') : null;
|
|
214
365
|
|
|
366
|
+
// A fresh clone or worktree has no .godot/ import cache, so no class_name
|
|
367
|
+
// resolves and every script that names one fails to parse. One headless
|
|
368
|
+
// import of the copy (never the developer's own directory) builds it.
|
|
369
|
+
await this._importIfNeeded();
|
|
370
|
+
|
|
215
371
|
const args = ['--path', this.projectCopy];
|
|
372
|
+
if (this.lockstep) {
|
|
373
|
+
this.ticksPerSecond = await projectTicksPerSecond(this.projectDir);
|
|
374
|
+
args.push('--fixed-fps', String(this.ticksPerSecond));
|
|
375
|
+
await this._isolateUserData();
|
|
376
|
+
}
|
|
216
377
|
if (recordVideo) args.push('--audio-driver', 'Dummy');
|
|
217
378
|
if (this.headless) args.push('--headless');
|
|
218
379
|
else args.push('--resolution', `${this.viewport.w}x${this.viewport.h}`);
|
|
@@ -230,6 +391,10 @@ export class GodotDriver {
|
|
|
230
391
|
await this.rpc.call('hello', { token: this.driverToken });
|
|
231
392
|
this.ping = await this.rpc.call('ping');
|
|
232
393
|
this.headless = Boolean(this.ping.headless);
|
|
394
|
+
if (Number(this.ping.time_scale) > 0) this._resumeScale = Number(this.ping.time_scale);
|
|
395
|
+
await this._checkBoot();
|
|
396
|
+
if (this.lockstep) await this._startLockstep();
|
|
397
|
+
else if (this.pauseBetweenActions) await this._freeze();
|
|
233
398
|
this._startedAt = this._now();
|
|
234
399
|
if (this.headless) {
|
|
235
400
|
this._consoleQueue.push({
|
|
@@ -257,25 +422,278 @@ export class GodotDriver {
|
|
|
257
422
|
}
|
|
258
423
|
}
|
|
259
424
|
|
|
425
|
+
async _importIfNeeded() {
|
|
426
|
+
let decision;
|
|
427
|
+
try {
|
|
428
|
+
decision = await this._needsImport(this.projectDir);
|
|
429
|
+
} catch {
|
|
430
|
+
decision = { needed: false };
|
|
431
|
+
}
|
|
432
|
+
if (!decision?.needed) return;
|
|
433
|
+
const result = await this._importProject({
|
|
434
|
+
godotPath: this.godot.path,
|
|
435
|
+
projectCopy: this.projectCopy,
|
|
436
|
+
env: this.launchEnv({ driver: false }),
|
|
437
|
+
timeoutMs: this.importTimeoutMs,
|
|
438
|
+
logPath: this.logPath,
|
|
439
|
+
spawnFn: this._spawn,
|
|
440
|
+
}).catch((error) => ({ ok: false, error: error.message }));
|
|
441
|
+
this.imported = { reason: decision.reason ?? null, ...result };
|
|
442
|
+
if (!result?.ok) {
|
|
443
|
+
this._consoleQueue.push({
|
|
444
|
+
kind: 'driver',
|
|
445
|
+
type: 'driver',
|
|
446
|
+
text: `the project import before launch did not finish (${result?.error ?? `exit ${result?.exitCode}`}); launching anyway`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Fail the launch, rather than hand a persona a black screen, when the game
|
|
453
|
+
* never got past loading its scripts or its main scene.
|
|
454
|
+
*/
|
|
455
|
+
async _checkBoot() {
|
|
456
|
+
await this.rpc.call('wait_frames', { n: BOOT_SETTLE_FRAMES }).catch(() => {});
|
|
457
|
+
// Godot's stdout arrives on its own stream; give the lines written while
|
|
458
|
+
// the game booted a moment to land before judging them.
|
|
459
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
460
|
+
const fatal = this._logTail.filter((line) => BOOT_FATAL_LINE.some((pattern) => pattern.test(line)));
|
|
461
|
+
let reason = null;
|
|
462
|
+
if (fatal.length > 0) {
|
|
463
|
+
reason = `the game's scripts did not load (${fatal.length} error line(s)): ${fatal.slice(0, 3).map((line) => line.trim()).join(' | ')}`;
|
|
464
|
+
} else {
|
|
465
|
+
const state = await this.rpc.call('get_state', { max_depth: 1, visible_only: true }).catch(() => null);
|
|
466
|
+
if (state && !state.current_scene && !state.current_scene_file) reason = 'no scene is loaded, so there is nothing to play';
|
|
467
|
+
}
|
|
468
|
+
if (!reason) return;
|
|
469
|
+
const error = new Error(`${BOOT_FAILED}: ${reason}. See ${this.logPath ?? 'the Godot output'}.`);
|
|
470
|
+
error.code = BOOT_FAILED;
|
|
471
|
+
error.fatal = true;
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* A save file or a settings file left by the last run would make this one
|
|
477
|
+
* start somewhere else, so a lockstep run gets an empty user:// of its own. The
|
|
478
|
+
* name goes in the temp copy's project.godot, and the directory is removed at
|
|
479
|
+
* stop, only when its path carries this marker.
|
|
480
|
+
*/
|
|
481
|
+
async _isolateUserData() {
|
|
482
|
+
const file = path.join(this.projectCopy, 'project.godot');
|
|
483
|
+
const name = `${USER_DIR_MARKER}/${randomBytes(8).toString('hex')}`;
|
|
484
|
+
let text = await fs.readFile(file, 'utf8');
|
|
485
|
+
text = setIniEntry(text, 'application', 'config/use_custom_user_dir', 'true');
|
|
486
|
+
text = setIniEntry(text, 'application', 'config/custom_user_dir_name', `"${name}"`);
|
|
487
|
+
// A window that never takes focus can never lose it, and losing it is what makes
|
|
488
|
+
// Godot drop every held key at a moment the desktop chooses.
|
|
489
|
+
text = setIniEntry(text, 'display', 'window/size/no_focus', 'true');
|
|
490
|
+
await fs.writeFile(file, text);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The addon stopped the clock and took the seed in its own _ready, before the
|
|
495
|
+
* main scene. This checks that happened and writes the trace's first two
|
|
496
|
+
* lines, the second being the state the game booted to.
|
|
497
|
+
*/
|
|
498
|
+
async _startLockstep() {
|
|
499
|
+
if (!this.ping?.lockstep) {
|
|
500
|
+
const error = new Error('This project did not start in lockstep, so the run could not be recorded for replay. The driver addon may be older than this CLI.');
|
|
501
|
+
error.fatal = true;
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
if (Number(this.ping.seed) !== this.seed) throw new Error(`The game started on seed ${this.ping.seed}, not the ${this.seed} it was given.`);
|
|
505
|
+
const { digest, tick } = await this.rpc.call('digest');
|
|
506
|
+
this.userDataDir = typeof this.ping.user_dir === 'string' ? this.ping.user_dir : null;
|
|
507
|
+
this.bootDigest = digest;
|
|
508
|
+
this.lastDigest = digest;
|
|
509
|
+
this.tick = tick;
|
|
510
|
+
this._readDeaths(digest);
|
|
511
|
+
if (this.recordTrace && this.runDir) {
|
|
512
|
+
this.trace = createTraceWriter(path.join(this.runDir, TRACE_FILE));
|
|
513
|
+
await this.trace.start({
|
|
514
|
+
engine: 'godot',
|
|
515
|
+
godot_version: this.ping.godot_version ?? null,
|
|
516
|
+
driver_version: this.ping.driver_version ?? null,
|
|
517
|
+
seed: this.seed,
|
|
518
|
+
ticks_per_second: this.ticksPerSecond,
|
|
519
|
+
headless: this.headless,
|
|
520
|
+
viewport: this.viewport,
|
|
521
|
+
...(this.traceHeader ?? {}),
|
|
522
|
+
}, digest);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** The newest `deaths` any _ravensight_state() hook reports, null if none does. */
|
|
527
|
+
_readDeaths(digest) {
|
|
528
|
+
const states = digest?.game_state;
|
|
529
|
+
if (!states || typeof states !== 'object') return;
|
|
530
|
+
for (const state of Object.values(states)) {
|
|
531
|
+
const value = state?.deaths;
|
|
532
|
+
if (typeof value === 'number' && Number.isFinite(value)) this.deaths = value;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* One lockstep request: apply these inputs, run exactly `ticks` ticks, stop,
|
|
538
|
+
* answer. The only way the game's clock moves in this mode, so the trace and
|
|
539
|
+
* the game cannot disagree about what happened.
|
|
540
|
+
*
|
|
541
|
+
* @param {Array<Object>} events
|
|
542
|
+
* @param {number} ticks
|
|
543
|
+
* @param {{action?: string, args?: Object, digest?: boolean, redacted?: boolean}} [record]
|
|
544
|
+
* @returns {Promise<{tick: number, digest?: Object}>}
|
|
545
|
+
*/
|
|
546
|
+
async step(events, ticks, { action = 'step', args = {}, digest = true, redacted = false } = {}) {
|
|
547
|
+
const whole = Math.min(Math.max(Math.round(Number(ticks) || 1), 1), MAX_STEP_TICKS);
|
|
548
|
+
const answer = await this.rpc.call(
|
|
549
|
+
'step',
|
|
550
|
+
{ events, ticks: whole, digest },
|
|
551
|
+
// A windowed game may be held to the display's rate.
|
|
552
|
+
{ timeoutMs: this.callTimeoutMs + whole * STEP_MS_PER_TICK },
|
|
553
|
+
);
|
|
554
|
+
const frame = this.tick;
|
|
555
|
+
this.tick = answer.tick;
|
|
556
|
+
if (Number.isInteger(answer.reasserted)) this.reasserted = answer.reasserted;
|
|
557
|
+
if (answer.digest) {
|
|
558
|
+
this.lastDigest = answer.digest;
|
|
559
|
+
this._readDeaths(answer.digest);
|
|
560
|
+
}
|
|
561
|
+
await this.trace?.step({
|
|
562
|
+
frame,
|
|
563
|
+
action,
|
|
564
|
+
args,
|
|
565
|
+
...(redacted ? { redacted: true } : { events: traceableEvents(events, answer.clicks) }),
|
|
566
|
+
ticks: whole,
|
|
567
|
+
state: answer.digest ?? null,
|
|
568
|
+
});
|
|
569
|
+
return answer;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
_ticksFor(ms) {
|
|
573
|
+
return Math.max(1, Math.round((ms / 1000) * this.ticksPerSecond));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async _performLockstep(action) {
|
|
577
|
+
const settle = Math.max(0, this.settleFrames);
|
|
578
|
+
switch (action.kind) {
|
|
579
|
+
case 'key': {
|
|
580
|
+
const keys = [action.key, ...(Array.isArray(action.keys) ? action.keys : [])]
|
|
581
|
+
.filter((key) => typeof key === 'string' && key !== '')
|
|
582
|
+
.slice(0, MAX_HELD_KEYS);
|
|
583
|
+
const holdMs = holdWallMs(action.holdMs, this._resumeScale);
|
|
584
|
+
const args = { key: action.key, keys: action.keys, hold_ms: action.holdMs };
|
|
585
|
+
await this.step(
|
|
586
|
+
keys.map((key) => ({ type: 'key', keycode: key, pressed: true })),
|
|
587
|
+
holdMs > 0 ? this._ticksFor(holdMs) : 1,
|
|
588
|
+
{ action: 'key', args, digest: false },
|
|
589
|
+
);
|
|
590
|
+
await this.step(
|
|
591
|
+
[...keys].reverse().map((key) => ({ type: 'key', keycode: key, pressed: false })),
|
|
592
|
+
Math.max(settle, 1),
|
|
593
|
+
{ action: 'key_release', args },
|
|
594
|
+
);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
case 'type': {
|
|
598
|
+
const text = String(action.text ?? '');
|
|
599
|
+
const redacted = this.traceSecrets.some((secret) => secret && text.includes(secret));
|
|
600
|
+
const args = redacted ? { redacted: true } : { text, submit: Boolean(action.submit) };
|
|
601
|
+
for (const char of text) {
|
|
602
|
+
const unicode = char.codePointAt(0);
|
|
603
|
+
await this.step(
|
|
604
|
+
[{ type: 'key', keycode: char, pressed: true, unicode }, { type: 'key', keycode: char, pressed: false, unicode }],
|
|
605
|
+
1,
|
|
606
|
+
{ action: 'type', args, digest: false, redacted },
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (action.submit) {
|
|
610
|
+
await this.step(
|
|
611
|
+
[{ type: 'key', keycode: 'Enter', pressed: true }, { type: 'key', keycode: 'Enter', pressed: false }],
|
|
612
|
+
1,
|
|
613
|
+
{ action: 'type_submit', args, digest: false },
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
await this.step([], Math.max(settle, 2), { action: 'settle', args: {} });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
case 'click': {
|
|
620
|
+
const target = action.target;
|
|
621
|
+
const where = typeof target === 'string'
|
|
622
|
+
? { node_path: target }
|
|
623
|
+
: { x: target?.x ?? 0, y: target?.y ?? 0 };
|
|
624
|
+
const event = { type: 'click', ...where, ...(action.button ? { button: action.button } : {}), ...(action.double ? { double: true } : {}) };
|
|
625
|
+
await this.step([event], 2 + settle, { action: 'click', args: where });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
case 'press_action': {
|
|
629
|
+
const ms = Math.min(Math.max(Number(action.duration_ms ?? 50), 0), MAX_HOLD_MS);
|
|
630
|
+
const args = { action: action.action, duration_ms: ms };
|
|
631
|
+
await this.step([{ type: 'action', action: action.action, pressed: true }], this._ticksFor(ms), { action: 'press_action', args, digest: false });
|
|
632
|
+
await this.step([{ type: 'action', action: action.action, pressed: false }], Math.max(settle, 1), { action: 'press_action_release', args });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
case 'wait': {
|
|
636
|
+
const ms = Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS);
|
|
637
|
+
await this.step([], ms > 0 ? this._ticksFor(ms) : 1, { action: 'wait', args: { ms } });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
default:
|
|
641
|
+
throw new Error(`godot driver cannot perform action kind "${action.kind}"`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* What this run measured, for a validation: game seconds rather than wall
|
|
647
|
+
* seconds, because a persona's thinking time is not time the player spent.
|
|
648
|
+
* `deaths` is null when the game exposes no count, never zero.
|
|
649
|
+
*/
|
|
650
|
+
playMetrics() {
|
|
651
|
+
return {
|
|
652
|
+
ticks: this.tick,
|
|
653
|
+
game_seconds: this.ticksPerSecond > 0 ? this.tick / this.ticksPerSecond : null,
|
|
654
|
+
deaths: this.deaths,
|
|
655
|
+
errors: this.errorCount,
|
|
656
|
+
replayable: Boolean(this.trace) && !this.trace.truncated,
|
|
657
|
+
inputs_reasserted: this.reasserted,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Park the game: its clock stops, rendering and input handling go on. */
|
|
662
|
+
async _freeze() {
|
|
663
|
+
const answer = await this.rpc.call('set_time_scale', { scale: 0 });
|
|
664
|
+
const previous = Number(answer?.previous);
|
|
665
|
+
// The scale the game was running at is the one to resume with, so a game
|
|
666
|
+
// that sets its own time scale keeps it.
|
|
667
|
+
if (Number.isFinite(previous) && previous > 0) this._resumeScale = previous;
|
|
668
|
+
this._paused = true;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async _resume() {
|
|
672
|
+
await this.rpc.call('set_time_scale', { scale: this._resumeScale });
|
|
673
|
+
this._paused = false;
|
|
674
|
+
}
|
|
675
|
+
|
|
260
676
|
/**
|
|
261
677
|
* The environment Godot is launched with. RAVENSIGHT_DRIVER* activate and
|
|
262
678
|
* authenticate the driver; the RAVENSIGHT_PLAYTEST_* set is read by the
|
|
263
679
|
* customer's own Ravensight.gd in _ready(), which is what makes every event
|
|
264
680
|
* the game tracks during this run carry synthetic / persona / pt_run tags.
|
|
265
681
|
*/
|
|
266
|
-
launchEnv() {
|
|
682
|
+
launchEnv({ driver = true } = {}) {
|
|
267
683
|
const { token, runId, jobId, persona } = this.playtest ?? {};
|
|
268
|
-
const
|
|
269
|
-
const out = {};
|
|
270
|
-
for (const [key, value] of Object.entries(this.env ?? {})) {
|
|
271
|
-
if (allowed.has(key.toLowerCase())) out[key] = value;
|
|
272
|
-
}
|
|
684
|
+
const out = allowlistedEnv(this.env);
|
|
273
685
|
Object.assign(out, this.extraEnv ?? {});
|
|
686
|
+
if (!driver) return out;
|
|
274
687
|
Object.assign(out, {
|
|
275
688
|
RAVENSIGHT_DRIVER: '1',
|
|
276
689
|
RAVENSIGHT_DRIVER_PORT: String(this.port),
|
|
277
690
|
RAVENSIGHT_DRIVER_TOKEN: this.driverToken,
|
|
278
691
|
});
|
|
692
|
+
if (this.lockstep) {
|
|
693
|
+
out.RAVENSIGHT_DRIVER_LOCKSTEP = '1';
|
|
694
|
+
out.RAVENSIGHT_DRIVER_SEED = String(this.seed);
|
|
695
|
+
out.RAVENSIGHT_DRIVER_TICKS = String(this.ticksPerSecond);
|
|
696
|
+
}
|
|
279
697
|
if (token) out.RAVENSIGHT_PLAYTEST_TOKEN = token;
|
|
280
698
|
if (runId) out.RAVENSIGHT_PLAYTEST_RUN_ID = runId;
|
|
281
699
|
if (jobId) out.RAVENSIGHT_PLAYTEST_JOB_ID = jobId;
|
|
@@ -290,9 +708,15 @@ export class GodotDriver {
|
|
|
290
708
|
} catch (error) {
|
|
291
709
|
const tail = this._logTail.slice(-15).join('\n');
|
|
292
710
|
const exited = this.exitCode === null ? '' : ` Godot exited with code ${this.exitCode}.`;
|
|
293
|
-
|
|
294
|
-
|
|
711
|
+
// The game never reached a state anything could play, which is what a boot
|
|
712
|
+
// failure is: the same word, so a fix whose verify met this is told apart from
|
|
713
|
+
// one whose persona ran and failed, and its unit comes back the same way.
|
|
714
|
+
const failure = new Error(
|
|
715
|
+
`${BOOT_FAILED}: ${error.message}.${exited}${tail ? `\nLast Godot output:\n${tail}` : ''}`,
|
|
295
716
|
);
|
|
717
|
+
failure.code = BOOT_FAILED;
|
|
718
|
+
failure.fatal = true;
|
|
719
|
+
throw failure;
|
|
296
720
|
}
|
|
297
721
|
}
|
|
298
722
|
|
|
@@ -339,6 +763,7 @@ export class GodotDriver {
|
|
|
339
763
|
continue;
|
|
340
764
|
}
|
|
341
765
|
if (ERROR_LINE.test(line.trim())) {
|
|
766
|
+
this.errorCount += 1;
|
|
342
767
|
this._consoleQueue.push({ kind: 'error', text: line.trim() });
|
|
343
768
|
}
|
|
344
769
|
}
|
|
@@ -364,13 +789,17 @@ export class GodotDriver {
|
|
|
364
789
|
state,
|
|
365
790
|
gameState,
|
|
366
791
|
step: this._step,
|
|
367
|
-
elapsedMs: this.
|
|
792
|
+
elapsedMs: this.lockstep
|
|
793
|
+
? Math.round((this.tick / this.ticksPerSecond) * 1000)
|
|
794
|
+
: (this._startedAt === null ? 0 : this._now() - this._startedAt),
|
|
368
795
|
});
|
|
369
796
|
const wantVision = vision ?? needsVision(observation);
|
|
370
797
|
if (wantVision && !this.headless) {
|
|
371
798
|
const shot = await this._rawScreenshot();
|
|
372
799
|
if (shot) {
|
|
373
|
-
|
|
800
|
+
// screenshotBase64 is the name the persona's tools read; the old
|
|
801
|
+
// screenshot_b64 is kept for anything that reads the observation raw.
|
|
802
|
+
observation = { ...observation, screenshot_b64: shot.data, screenshotBase64: shot.data, screenshotMediaType: 'image/png' };
|
|
374
803
|
}
|
|
375
804
|
}
|
|
376
805
|
// changed detection compares outline hashes only, never this observation's
|
|
@@ -389,21 +818,41 @@ export class GodotDriver {
|
|
|
389
818
|
if (action.kind === 'type' && (action.replace || action.target)) {
|
|
390
819
|
return { ok: false, error: 'replace (empty a field, then type) and a typing target are only available on playwright_web; this driver can only type more text into what has focus', changed: false };
|
|
391
820
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
821
|
+
if (this.lockstep && action.kind !== 'quit' && action.kind !== 'set_time_scale') {
|
|
822
|
+
try {
|
|
823
|
+
await this._performLockstep(action);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
if (error instanceof DriverRpcError) {
|
|
826
|
+
return { ok: false, error: error.rpcMessage, code: error.code, data: error.data, changed: false };
|
|
827
|
+
}
|
|
828
|
+
throw error;
|
|
397
829
|
}
|
|
398
|
-
|
|
830
|
+
const { hash } = await this._snapshot();
|
|
831
|
+
const changed = this._lastHash !== null && hash !== this._lastHash;
|
|
832
|
+
this._lastHash = hash;
|
|
833
|
+
return { ok: true, changed };
|
|
399
834
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
835
|
+
const pausing = this.pauseBetweenActions && this._paused && action.kind !== 'quit' && action.kind !== 'set_time_scale';
|
|
836
|
+
try {
|
|
837
|
+
if (pausing) await this._resume();
|
|
838
|
+
try {
|
|
839
|
+
await this._perform(action);
|
|
840
|
+
} catch (error) {
|
|
841
|
+
if (error instanceof DriverRpcError) {
|
|
842
|
+
return { ok: false, error: error.rpcMessage, code: error.code, data: error.data, changed: false };
|
|
843
|
+
}
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
846
|
+
if (action.kind === 'quit') return { ok: true, changed: false };
|
|
847
|
+
// Input reaches the game a frame after it is parsed, and a handler that
|
|
848
|
+
// changes a label runs a frame after that, so a snapshot taken the instant
|
|
849
|
+
// an action returns reports the screen as it was one action ago. Settling
|
|
850
|
+
// first is what makes "changed" mean what the persona loop thinks it means.
|
|
851
|
+
if (this.settleFrames > 0) {
|
|
852
|
+
await this.rpc.call('wait_frames', { n: this.settleFrames }).catch(() => {});
|
|
853
|
+
}
|
|
854
|
+
} finally {
|
|
855
|
+
if (pausing && this.rpc && !this.rpc.closed) await this._freeze().catch(() => {});
|
|
407
856
|
}
|
|
408
857
|
const { hash } = await this._snapshot();
|
|
409
858
|
const changed = this._lastHash !== null && hash !== this._lastHash;
|
|
@@ -413,11 +862,30 @@ export class GodotDriver {
|
|
|
413
862
|
|
|
414
863
|
async _perform(action) {
|
|
415
864
|
switch (action.kind) {
|
|
416
|
-
case 'key':
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
865
|
+
case 'key': {
|
|
866
|
+
// Every key goes down, the game runs for the hold, then every key that
|
|
867
|
+
// went down comes back up, in reverse order, even when a later press
|
|
868
|
+
// was refused: a key left held would steer the rest of the run.
|
|
869
|
+
const keys = [action.key, ...(Array.isArray(action.keys) ? action.keys : [])]
|
|
870
|
+
.filter((key) => typeof key === 'string' && key !== '')
|
|
871
|
+
.slice(0, MAX_HELD_KEYS);
|
|
872
|
+
const holdMs = holdWallMs(action.holdMs, this._resumeScale);
|
|
873
|
+
const down = [];
|
|
874
|
+
try {
|
|
875
|
+
for (const key of keys) {
|
|
876
|
+
await this.rpc.call('key', { keycode: key, pressed: true });
|
|
877
|
+
down.push(key);
|
|
878
|
+
}
|
|
879
|
+
// A tap is one frame, as it always was; a hold is game time.
|
|
880
|
+
if (holdMs > 0) await this._sleep(holdMs);
|
|
881
|
+
else await this.rpc.call('wait_frames', { n: 1 });
|
|
882
|
+
} finally {
|
|
883
|
+
for (const key of down.reverse()) {
|
|
884
|
+
await this.rpc.call('key', { keycode: key, pressed: false }).catch(() => {});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
420
887
|
return;
|
|
888
|
+
}
|
|
421
889
|
case 'type':
|
|
422
890
|
for (const char of String(action.text ?? '')) {
|
|
423
891
|
await this.rpc.call('key', { keycode: char, pressed: true, unicode: char.codePointAt(0) });
|
|
@@ -452,8 +920,10 @@ export class GodotDriver {
|
|
|
452
920
|
);
|
|
453
921
|
return;
|
|
454
922
|
case 'wait': {
|
|
455
|
-
|
|
456
|
-
|
|
923
|
+
// On the clock, like a hold: a frame count is display rate dependent.
|
|
924
|
+
const ms = Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS);
|
|
925
|
+
if (ms > 0) await this._sleep(ms);
|
|
926
|
+
else await this.rpc.call('wait_frames', { n: 1 });
|
|
457
927
|
return;
|
|
458
928
|
}
|
|
459
929
|
case 'set_time_scale':
|
|
@@ -506,9 +976,12 @@ export class GodotDriver {
|
|
|
506
976
|
this._shotIndex += 1;
|
|
507
977
|
const dir = path.join(this.runDir ?? '.', 'screenshots');
|
|
508
978
|
await fs.mkdir(dir, { recursive: true });
|
|
509
|
-
|
|
979
|
+
// relativePath is what a report cites as evidence; without it every
|
|
980
|
+
// Godot screenshot reached the report as screenshots/undefined.
|
|
981
|
+
const relativePath = path.join('screenshots', screenshotName(this._shotIndex, name));
|
|
982
|
+
const file = path.join(this.runDir ?? '.', relativePath);
|
|
510
983
|
await fs.writeFile(file, Buffer.from(shot.data, 'base64'));
|
|
511
|
-
return { path: file, width: shot.width, height: shot.height };
|
|
984
|
+
return { path: file, relativePath, width: shot.width, height: shot.height };
|
|
512
985
|
}
|
|
513
986
|
|
|
514
987
|
/** Console errors since the previous call, from Godot stdout and driver events. */
|
|
@@ -516,7 +989,9 @@ export class GodotDriver {
|
|
|
516
989
|
const fromEvents = (this.rpc?.drainEvents() ?? [])
|
|
517
990
|
.filter((event) => event.type === 'error')
|
|
518
991
|
.map((event) => ({ kind: 'error', text: event.message, source: event.source, frame: event.frame }));
|
|
519
|
-
|
|
992
|
+
// The persona loop labels a console note by its `type`, as the browser
|
|
993
|
+
// drivers report it; without one every note read "undefined: ...".
|
|
994
|
+
const out = [...this._consoleQueue, ...fromEvents].map((entry) => ({ type: entry.kind, ...entry }));
|
|
520
995
|
this._consoleQueue = [];
|
|
521
996
|
return out;
|
|
522
997
|
}
|
|
@@ -631,6 +1106,7 @@ export class GodotDriver {
|
|
|
631
1106
|
async _stop({ removeTemp = !this.keepTemp, reason = 'stopped' } = {}) {
|
|
632
1107
|
this._stopped = true;
|
|
633
1108
|
this._signal?.removeEventListener('abort', this._onAbort);
|
|
1109
|
+
await this.trace?.end({ tick: this.tick, reason, deaths: this.deaths, errors: this.errorCount }).catch(() => {});
|
|
634
1110
|
if (this._recordingStartedAt != null) this._endCapture(reason);
|
|
635
1111
|
await this._capturePromise;
|
|
636
1112
|
if (this.rpc && !this.rpc.closed) {
|
|
@@ -638,11 +1114,17 @@ export class GodotDriver {
|
|
|
638
1114
|
}
|
|
639
1115
|
await this._waitForExit(this.exitTimeoutMs);
|
|
640
1116
|
this.rpc?.close();
|
|
1117
|
+
if (this.userDataDir?.includes(USER_DIR_MARKER)) {
|
|
1118
|
+
await fs.rm(this.userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
1119
|
+
this.userDataDir = null;
|
|
1120
|
+
}
|
|
641
1121
|
if (removeTemp && this.tempRoot) {
|
|
642
1122
|
await fs.rm(this.tempRoot, { recursive: true, force: true }).catch(() => {});
|
|
643
1123
|
this.tempRoot = null;
|
|
644
1124
|
this.projectCopy = null;
|
|
645
1125
|
}
|
|
1126
|
+
this._untrack?.();
|
|
1127
|
+
this._untrack = null;
|
|
646
1128
|
return {
|
|
647
1129
|
videoPath: null, ...await this._finalizeRecording(),
|
|
648
1130
|
frameCount: this._frameIndex, chapters: this.chapters,
|
|
@@ -669,16 +1151,21 @@ export class GodotDriver {
|
|
|
669
1151
|
}
|
|
670
1152
|
}
|
|
671
1153
|
|
|
672
|
-
|
|
673
|
-
|
|
1154
|
+
/**
|
|
1155
|
+
* The environment for any Godot process: `ENV_ALLOWLIST` and nothing else.
|
|
1156
|
+
*
|
|
1157
|
+
* @param {Object} [env]
|
|
1158
|
+
* @returns {Object}
|
|
1159
|
+
*/
|
|
1160
|
+
export function allowlistedEnv(env = process.env) {
|
|
1161
|
+
const allowed = new Set(ENV_ALLOWLIST.map((name) => name.toLowerCase()));
|
|
1162
|
+
const out = {};
|
|
1163
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
1164
|
+
if (allowed.has(key.toLowerCase())) out[key] = value;
|
|
1165
|
+
}
|
|
1166
|
+
return out;
|
|
674
1167
|
}
|
|
675
1168
|
|
|
676
|
-
function
|
|
677
|
-
return (
|
|
678
|
-
String(name ?? 'shot')
|
|
679
|
-
.toLowerCase()
|
|
680
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
681
|
-
.replace(/^-+|-+$/g, '')
|
|
682
|
-
.slice(0, 40) || 'shot'
|
|
683
|
-
);
|
|
1169
|
+
export function createGodotDriver(options) {
|
|
1170
|
+
return new GodotDriver(options);
|
|
684
1171
|
}
|