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.
Files changed (99) hide show
  1. package/README.md +319 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +120 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +233 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +647 -70
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/lists.js +206 -0
  24. package/src/commands/profile.js +25 -6
  25. package/src/commands/punch.js +116 -0
  26. package/src/commands/replay.js +204 -0
  27. package/src/commands/resume.js +7 -0
  28. package/src/commands/run.js +200 -24
  29. package/src/commands/tape.js +74 -0
  30. package/src/commands/template.js +70 -0
  31. package/src/commands/validate.js +414 -0
  32. package/src/commands/validateHosted.js +292 -0
  33. package/src/create/scaffold.js +192 -0
  34. package/src/run/acceptance.js +212 -0
  35. package/src/run/args.js +18 -1
  36. package/src/run/checks.js +705 -0
  37. package/src/run/dimension.js +95 -0
  38. package/src/run/drivers/godot-observation.js +59 -11
  39. package/src/run/drivers/godot-project.js +201 -31
  40. package/src/run/drivers/godot.js +532 -45
  41. package/src/run/drivers/web.js +18 -0
  42. package/src/run/evidence.js +170 -0
  43. package/src/run/featureLoop.js +288 -0
  44. package/src/run/fixLoop.js +82 -12
  45. package/src/run/git.js +110 -20
  46. package/src/run/json.js +65 -9
  47. package/src/run/labsOutcome.js +136 -0
  48. package/src/run/liveWork.js +103 -0
  49. package/src/run/model.js +105 -5
  50. package/src/run/openPr.js +107 -0
  51. package/src/run/paths.js +4 -1
  52. package/src/run/pendingRegister.js +81 -0
  53. package/src/run/personaLoop.js +111 -4
  54. package/src/run/profile.js +68 -7
  55. package/src/run/replay.js +236 -0
  56. package/src/run/repoTools.js +227 -27
  57. package/src/run/report.js +7 -1
  58. package/src/run/resume.js +19 -1
  59. package/src/run/secretScan.js +35 -0
  60. package/src/run/settle.js +41 -0
  61. package/src/run/tools.js +33 -4
  62. package/src/run/trace.js +124 -0
  63. package/src/simulation/godotPack.js +112 -0
  64. package/src/ui/index.js +61 -9
  65. package/src/upload/allowlist.js +6 -2
  66. package/src/upload/index.js +5 -4
  67. package/templates/godot-2d/README.md +36 -0
  68. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  69. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  70. package/templates/godot-2d/export_presets.cfg +27 -0
  71. package/templates/godot-2d/gitignore +10 -0
  72. package/templates/godot-2d/project.godot +58 -0
  73. package/templates/godot-2d/scenes/main.tscn +67 -0
  74. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  75. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  76. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  77. package/templates/godot-2d/scripts/main.gd +32 -0
  78. package/templates/godot-2d/scripts/player.gd +10 -0
  79. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  80. package/templates/godot-2d/template.json +14 -0
  81. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  82. package/templates/godot-2d/tools/selftest.gd +70 -0
  83. package/templates/godot-3d/README.md +49 -0
  84. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  85. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  86. package/templates/godot-3d/export_presets.cfg +27 -0
  87. package/templates/godot-3d/gitignore +10 -0
  88. package/templates/godot-3d/project.godot +56 -0
  89. package/templates/godot-3d/scenes/main.tscn +154 -0
  90. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  91. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  92. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  93. package/templates/godot-3d/scripts/beacon.gd +20 -0
  94. package/templates/godot-3d/scripts/main.gd +49 -0
  95. package/templates/godot-3d/scripts/player.gd +19 -0
  96. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  97. package/templates/godot-3d/template.json +16 -0
  98. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  99. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -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
- findFreePort,
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
- const FRAMES_PER_SECOND = 60;
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 = findFreePort,
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 allowed = new Set(ENV_ALLOWLIST.map((name) => name.toLowerCase()));
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
- throw new Error(
294
- `${error.message}.${exited}${tail ? `\nLast Godot output:\n${tail}` : ''}`,
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._startedAt === null ? 0 : this._now() - this._startedAt,
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
- observation = { ...observation, screenshot_b64: shot.data };
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
- try {
393
- await this._perform(action);
394
- } catch (error) {
395
- if (error instanceof DriverRpcError) {
396
- return { ok: false, error: error.rpcMessage, code: error.code, data: error.data, changed: false };
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
- throw error;
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
- if (action.kind === 'quit') return { ok: true, changed: false };
401
- // Input reaches the game a frame after it is parsed, and a handler that
402
- // changes a label runs a frame after that, so a snapshot taken the instant
403
- // an action returns reports the screen as it was one action ago. Settling
404
- // first is what makes "changed" mean what the persona loop thinks it means.
405
- if (this.settleFrames > 0) {
406
- await this.rpc.call('wait_frames', { n: this.settleFrames }).catch(() => {});
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
- await this.rpc.call('key', { keycode: action.key, pressed: true });
418
- await this.rpc.call('wait_frames', { n: 1 });
419
- await this.rpc.call('key', { keycode: action.key, pressed: false });
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
- const frames = Math.max(1, Math.round(((action.ms ?? 0) / 1000) * FRAMES_PER_SECOND));
456
- await this.rpc.call('wait_frames', { n: frames }, { timeoutMs: this.callTimeoutMs + (action.ms ?? 0) });
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
- const file = path.join(dir, `${String(this._shotIndex).padStart(2, '0')}-${slug(name)}.png`);
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
- const out = [...this._consoleQueue, ...fromEvents];
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
- export function createGodotDriver(options) {
673
- return new GodotDriver(options);
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 slug(name) {
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
  }