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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,138 @@
1
+ // Turns the driver's raw scene tree into the text observation a persona reads.
2
+ //
3
+ // Two jobs beyond formatting. First, keep the outline small: the raw tree of a
4
+ // real game is thousands of nodes, and spec 07 wants the observation under
5
+ // roughly 3k tokens, so a subtree with nothing a player could see or press is
6
+ // dropped. Second, carry the file paths: every scene file and script path in
7
+ // the tree is collected and listed, because a finding that says "the Begin
8
+ // button does nothing" is only actionable if the report can name the scene and
9
+ // script to open.
10
+ import { createHash } from 'node:crypto';
11
+
12
+ export const MAX_TEXT_CHARS = 12000;
13
+
14
+ const PRESSABLE = /Button|LineEdit|TextEdit|Slider|CheckBox|CheckButton|OptionButton|ItemList|Tree/;
15
+
16
+ /**
17
+ * @param {object} input
18
+ * @param {object} input.state result of the driver's get_state
19
+ * @param {object} [input.gameState] result of get_game_state
20
+ * @param {number} input.step
21
+ * @param {number} input.elapsedMs
22
+ */
23
+ export function buildObservation({
24
+ state,
25
+ gameState = null,
26
+ step = 0,
27
+ elapsedMs = 0,
28
+ maxChars = MAX_TEXT_CHARS,
29
+ screenshotBase64 = null,
30
+ } = {}) {
31
+ const collected = { scenes: [], scripts: [] };
32
+ const tree = state?.tree ?? null;
33
+ const kept = tree ? prune(tree, collected) : null;
34
+ const lines = [];
35
+
36
+ const scenePath = state?.current_scene_file || '';
37
+ if (scenePath) lines.push(`scene: ${scenePath}`);
38
+ if (state?.current_scene) lines.push(`scene node: ${state.current_scene}`);
39
+ if (collected.scripts.length > 0) lines.push(`scripts: ${collected.scripts.join(', ')}`);
40
+ if (state?.truncated) lines.push('note: the scene tree was truncated');
41
+ if (lines.length > 0) lines.push('');
42
+
43
+ if (kept) {
44
+ render(kept, 0, lines);
45
+ } else {
46
+ lines.push('(no visible nodes)');
47
+ }
48
+
49
+ const states = gameState?.states ?? {};
50
+ const stateKeys = Object.keys(states);
51
+ if (stateKeys.length > 0) {
52
+ lines.push('', 'game state:');
53
+ for (const key of stateKeys) {
54
+ lines.push(` ${key}: ${JSON.stringify(states[key])}`);
55
+ }
56
+ }
57
+
58
+ const text = truncate(lines.join('\n'), maxChars);
59
+ const observation = {
60
+ step,
61
+ text,
62
+ structured: { state, gameState },
63
+ elapsed_ms: elapsedMs,
64
+ frame: state?.frame ?? null,
65
+ scene: scenePath,
66
+ scenes: collected.scenes,
67
+ scripts: collected.scripts,
68
+ hash: createHash('sha1').update(text).digest('hex'),
69
+ };
70
+ if (screenshotBase64) observation.screenshot_b64 = screenshotBase64;
71
+ return observation;
72
+ }
73
+
74
+ /** True when the outline is too thin to play from, so vision is worth the cost. */
75
+ export function needsVision(observation, { minChars = 200 } = {}) {
76
+ const body = String(observation?.text ?? '')
77
+ .split('\n')
78
+ .filter((line) => !/^(scene|scene node|scripts|note):/.test(line))
79
+ .join('\n')
80
+ .trim();
81
+ return body.length < minChars;
82
+ }
83
+
84
+ function prune(node, collected) {
85
+ if (node.scene && !collected.scenes.includes(node.scene)) collected.scenes.push(node.scene);
86
+ if (node.script && !collected.scripts.includes(node.script)) collected.scripts.push(node.script);
87
+
88
+ const children = [];
89
+ for (const child of node.children ?? []) {
90
+ const keptChild = prune(child, collected);
91
+ if (keptChild) children.push(keptChild);
92
+ }
93
+ const selfInteresting =
94
+ Boolean(node.text) ||
95
+ Boolean(node.script) ||
96
+ Boolean(node.scene) ||
97
+ (node.groups ?? []).length > 0 ||
98
+ PRESSABLE.test(node.type ?? '') ||
99
+ node.child_count > 0;
100
+ if (!selfInteresting && children.length === 0) return null;
101
+ return { ...node, children };
102
+ }
103
+
104
+ function render(node, depth, lines) {
105
+ lines.push(`${' '.repeat(depth)}${describe(node)}`);
106
+ for (const child of node.children ?? []) render(child, depth + 1, lines);
107
+ }
108
+
109
+ function describe(node) {
110
+ const parts = [node.type ?? 'Node', node.name ?? ''];
111
+ if (node.text) parts.push(JSON.stringify(collapse(node.text)));
112
+ if (Array.isArray(node.position)) {
113
+ parts.push(`@(${node.position.map((value) => round(value)).join(',')})`);
114
+ }
115
+ const flags = [];
116
+ if (node.focus) flags.push('focus');
117
+ if (node.disabled) flags.push('disabled');
118
+ if (node.visible === false) flags.push('hidden');
119
+ if ((node.groups ?? []).length > 0) flags.push(`groups=${node.groups.join('|')}`);
120
+ if (node.child_count > 0) flags.push(`+${node.child_count} more`);
121
+ if (flags.length > 0) parts.push(`[${flags.join(' ')}]`);
122
+ if (node.scene) parts.push(`{${node.scene}}`);
123
+ if (node.script) parts.push(`<${node.script}>`);
124
+ return parts.filter(Boolean).join(' ');
125
+ }
126
+
127
+ function collapse(text) {
128
+ return String(text).replace(/\s+/g, ' ').trim().slice(0, 200);
129
+ }
130
+
131
+ function round(value) {
132
+ return typeof value === 'number' ? Math.round(value) : value;
133
+ }
134
+
135
+ function truncate(text, maxChars) {
136
+ if (text.length <= maxChars) return text;
137
+ return `${text.slice(0, maxChars - 20)}\n... (truncated)`;
138
+ }
@@ -0,0 +1,475 @@
1
+ // Everything the Godot driver has to do to a project before Godot starts.
2
+ //
3
+ // The customer's project directory is read only as far as this module is
4
+ // concerned: spec 07 requires it to be byte identical after a run. So the
5
+ // driver copies the project to a temporary directory, injects the addon there,
6
+ // and launches Godot with --path pointing at the copy.
7
+ //
8
+ // The autoload is registered twice in that copy, in override.cfg (the design in
9
+ // spec 07) and in the copy's own project.godot (the fallback spec 07 lists
10
+ // under its open question 1, because override.cfg autoloads are not verified
11
+ // across every project layout). Both name the same script, so registering both
12
+ // is idempotent, and one of them is guaranteed to be the mechanism Godot
13
+ // honors.
14
+ import { execFile } from 'node:child_process';
15
+ import net from 'node:net';
16
+ import fs from 'node:fs/promises';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { promisify } from 'node:util';
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ export const ADDON_DIR_NAME = 'ravensight_driver';
24
+ export const AUTOLOAD_NAME = 'RavensightDriver';
25
+ export const AUTOLOAD_VALUE = `*res://addons/${ADDON_DIR_NAME}/driver.gd`;
26
+ export const DEFAULT_EXCLUDES = ['.git', 'node_modules', '.DS_Store'];
27
+ // A runaway copy of a repository with gigabytes of raw assets in it is a worse
28
+ // failure than a clear refusal, so sizing comes first.
29
+ export const DEFAULT_MAX_PROJECT_BYTES = 2 * 1024 * 1024 * 1024;
30
+
31
+ /** Resolves the addon that ships inside this package. */
32
+ export function packagedAddonDir() {
33
+ return path.resolve(import.meta.dirname, '../../../addons', ADDON_DIR_NAME);
34
+ }
35
+
36
+ export async function isGodotProject(projectDir) {
37
+ try {
38
+ await fs.access(path.join(projectDir, 'project.godot'));
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Sums the bytes that would be copied, stopping as soon as the cap is passed so
47
+ * a huge tree is not walked to the end just to be refused.
48
+ */
49
+ export async function measureProject(dir, { excludes = DEFAULT_EXCLUDES, maxBytes = DEFAULT_MAX_PROJECT_BYTES } = {}) {
50
+ let total = 0;
51
+ const stack = [dir];
52
+ const seen = new Set();
53
+ const realRoot = await realRootOf(dir);
54
+ while (stack.length > 0) {
55
+ const current = stack.pop();
56
+ const entries = await fs.readdir(current, { withFileTypes: true });
57
+ for (const entry of entries) {
58
+ if (excludes.includes(entry.name)) continue;
59
+ const full = path.join(current, entry.name);
60
+ // readdir reports a symlink as neither a file nor a directory, so an
61
+ // earlier version of this walk skipped symlinked trees entirely: the copy
62
+ // then dereferenced them and blew straight through the cap. Sizing has to
63
+ // follow whatever the copy will follow.
64
+ let stat;
65
+ if (entry.isSymbolicLink()) {
66
+ const real = await fs.realpath(full).catch(() => null);
67
+ // A broken link costs nothing, and a link that points back at something
68
+ // already counted must not be walked twice or forever.
69
+ if (!real || seen.has(real)) continue;
70
+ // Same rule the copy filter applies, so sizing and copying agree about
71
+ // what they follow. Without it this walk counted the whole project twice.
72
+ if (resolvesIntoOwnAncestry(real, realRoot)) continue;
73
+ seen.add(real);
74
+ stat = await fs.stat(real).catch(() => null);
75
+ if (!stat) continue;
76
+ if (stat.isDirectory()) {
77
+ stack.push(real);
78
+ continue;
79
+ }
80
+ } else if (entry.isDirectory()) {
81
+ stack.push(full);
82
+ continue;
83
+ } else if (!entry.isFile()) {
84
+ continue;
85
+ } else {
86
+ stat = await fs.stat(full);
87
+ }
88
+ total += stat.size;
89
+ if (total > maxBytes) {
90
+ throw new Error(
91
+ `project at ${dir} is larger than ${Math.round(maxBytes / 1e6)} MB, refusing to copy it; pass a smaller --godot-project`,
92
+ );
93
+ }
94
+ }
95
+ }
96
+ return total;
97
+ }
98
+
99
+ /**
100
+ * True when a symlink target is the project directory itself or one of its
101
+ * ancestors, which is the shape that makes a dereferencing copy unbounded.
102
+ *
103
+ * `selfref -> .` is the whole problem: dereferenced, the copy walks the project
104
+ * into itself, finds the link again, and repeats until PATH_MAX. `up -> ..` is
105
+ * the same defect one level out, and would drag the entire tree above the
106
+ * project into the copy besides. Neither is anything a Godot project needs, so
107
+ * both are simply dropped.
108
+ *
109
+ * Both arguments must already be realpaths. path.resolve() is not enough: on
110
+ * macOS os.tmpdir() is /var/folders/..., a symlink to /private/var/folders/...,
111
+ * so a project there compared by resolved-but-not-real paths never matches its
112
+ * own symlink target and the rule silently does nothing.
113
+ */
114
+ function resolvesIntoOwnAncestry(realTarget, realRoot) {
115
+ return realRoot === realTarget || realRoot.startsWith(realTarget + path.sep);
116
+ }
117
+
118
+ /** The project directory with every symlink in it resolved. */
119
+ async function realRootOf(dir) {
120
+ return fs.realpath(dir).catch(() => path.resolve(dir));
121
+ }
122
+
123
+ /**
124
+ * Replaces a symlink inside the copy with a real file or directory holding the
125
+ * same content.
126
+ *
127
+ * This is the whole reason the copy is safe. `project.godot`, `override.cfg` or
128
+ * `addons/` can each be a symlink pointing outside the project, and writing to
129
+ * a symlink writes to its target: the injection would land in the developer's
130
+ * real files, permanently, and spec 07 requires the project to be byte
131
+ * identical after a run. Every path this module writes to is materialized first.
132
+ */
133
+ async function materialize(target) {
134
+ let info;
135
+ try {
136
+ info = await fs.lstat(target);
137
+ } catch {
138
+ return;
139
+ }
140
+ if (!info.isSymbolicLink()) return;
141
+ const real = await fs.realpath(target).catch(() => null);
142
+ // unlink, never rm: it removes the link itself and never touches the target.
143
+ await fs.unlink(target);
144
+ if (!real) return;
145
+ const stat = await fs.stat(real).catch(() => null);
146
+ if (!stat) return;
147
+ if (stat.isDirectory()) {
148
+ await fs.cp(real, target, { recursive: true, dereference: true, force: true });
149
+ } else {
150
+ await fs.copyFile(real, target);
151
+ }
152
+ }
153
+
154
+ /** Materializes every component of a path inside the copy, outermost first. */
155
+ async function materializePath(root, relativePath) {
156
+ const parts = relativePath.split('/').filter(Boolean);
157
+ let current = root;
158
+ for (const part of parts) {
159
+ current = path.join(current, part);
160
+ await materialize(current);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Copies the project and injects the driver addon.
166
+ * Returns the copy's path plus the temp root to delete when the run ends.
167
+ */
168
+ export async function prepareProjectCopy({
169
+ projectDir,
170
+ addonDir = packagedAddonDir(),
171
+ tempRoot,
172
+ excludes = DEFAULT_EXCLUDES,
173
+ maxBytes = DEFAULT_MAX_PROJECT_BYTES,
174
+ } = {}) {
175
+ if (!(await isGodotProject(projectDir))) {
176
+ throw new Error(`no project.godot in ${projectDir}`);
177
+ }
178
+ await measureProject(projectDir, { excludes, maxBytes });
179
+
180
+ const root = tempRoot ?? (await fs.mkdtemp(path.join(os.tmpdir(), 'ravensight-playtest-')));
181
+ const projectCopy = path.join(root, 'project');
182
+ try {
183
+ // 0o700: the copy carries whatever the repository carries, including any
184
+ // local .env files, so no other user on the machine gets to read it.
185
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
186
+ await fs.chmod(root, 0o700);
187
+
188
+ // One dereference of any given directory. measureProject counts a symlinked
189
+ // tree once, so the copy has to copy it once, or the two disagree and the
190
+ // cap stops meaning anything.
191
+ const visited = new Set();
192
+ const realProjectDir = await realRootOf(projectDir);
193
+ // dereference: a symlink copied as a symlink still points at the developer's
194
+ // real file, and every write below would go straight through it.
195
+ await fs.cp(projectDir, projectCopy, {
196
+ recursive: true,
197
+ force: true,
198
+ dereference: true,
199
+ filter: async (source) => {
200
+ if (excludes.includes(path.basename(source))) return false;
201
+ const link = await fs.lstat(source).catch(() => null);
202
+ if (!link?.isSymbolicLink()) return true;
203
+ // fs.cp throws ENOENT on a symlink it cannot dereference, and a project
204
+ // with one broken link in it is not a project that should fail to launch.
205
+ const real = await fs.realpath(source).catch(() => null);
206
+ if (!real) return false;
207
+ if (resolvesIntoOwnAncestry(real, realProjectDir)) return false;
208
+ if (visited.has(real)) return false;
209
+ visited.add(real);
210
+ return true;
211
+ },
212
+ });
213
+
214
+ // Belt and braces: dereference covers the copy, materialize covers anything
215
+ // it left behind, so the three paths written below cannot be links.
216
+ await materializePath(projectCopy, `addons/${ADDON_DIR_NAME}`);
217
+ await fs.cp(addonDir, path.join(projectCopy, 'addons', ADDON_DIR_NAME), {
218
+ recursive: true,
219
+ force: true,
220
+ dereference: true,
221
+ });
222
+ await writeOverrideCfg(projectCopy);
223
+ await patchProjectGodot(projectCopy);
224
+ return { tempRoot: root, projectCopy };
225
+ } catch (error) {
226
+ // A half written copy is of no use to anyone and the caller has no handle on
227
+ // it yet, so it does not get to outlive the failure. Only a root this
228
+ // function created is removed: a tempRoot the caller passed in is theirs.
229
+ if (!tempRoot) await fs.rm(root, { recursive: true, force: true }).catch(() => {});
230
+ throw error;
231
+ }
232
+ }
233
+
234
+ async function writeOverrideCfg(projectCopy) {
235
+ await materializePath(projectCopy, 'override.cfg');
236
+ const overridePath = path.join(projectCopy, 'override.cfg');
237
+ const existing = await readIfPresent(overridePath);
238
+ const next = setIniEntry(existing ?? '', 'autoload', AUTOLOAD_NAME, `"${AUTOLOAD_VALUE}"`);
239
+ await fs.writeFile(overridePath, next, 'utf8');
240
+ return overridePath;
241
+ }
242
+
243
+ async function patchProjectGodot(projectCopy) {
244
+ await materializePath(projectCopy, 'project.godot');
245
+ const projectPath = path.join(projectCopy, 'project.godot');
246
+ const existing = await fs.readFile(projectPath, 'utf8');
247
+ const next = setIniEntry(existing, 'autoload', AUTOLOAD_NAME, `"${AUTOLOAD_VALUE}"`);
248
+ await fs.writeFile(projectPath, next, 'utf8');
249
+ return projectPath;
250
+ }
251
+
252
+ async function readIfPresent(file) {
253
+ try {
254
+ return await fs.readFile(file, 'utf8');
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Sets one key inside one section of a Godot style config file, preserving
262
+ * every other line, including autoloads the project already registers.
263
+ */
264
+ export function setIniEntry(text, section, key, value) {
265
+ const header = `[${section}]`;
266
+ const lines = text.length > 0 ? text.split('\n') : [];
267
+ const sectionStart = lines.findIndex((line) => line.trim() === header);
268
+ const entry = `${key}=${value}`;
269
+
270
+ if (sectionStart === -1) {
271
+ const body = text.replace(/\s*$/, '');
272
+ const prefix = body.length > 0 ? `${body}\n\n` : '';
273
+ return `${prefix}${header}\n\n${entry}\n`;
274
+ }
275
+
276
+ let sectionEnd = lines.length;
277
+ for (let i = sectionStart + 1; i < lines.length; i += 1) {
278
+ if (/^\[[^\]]+\]\s*$/.test(lines[i].trim())) {
279
+ sectionEnd = i;
280
+ break;
281
+ }
282
+ }
283
+ const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
284
+ for (let i = sectionStart + 1; i < sectionEnd; i += 1) {
285
+ if (keyPattern.test(lines[i])) {
286
+ lines[i] = entry;
287
+ return lines.join('\n');
288
+ }
289
+ }
290
+ const insertAt = lastNonEmpty(lines, sectionStart + 1, sectionEnd) + 1;
291
+ lines.splice(insertAt, 0, entry);
292
+ return lines.join('\n');
293
+ }
294
+
295
+ function lastNonEmpty(lines, from, to) {
296
+ let last = from - 1;
297
+ for (let i = from; i < to; i += 1) {
298
+ if (lines[i].trim() !== '') last = i;
299
+ }
300
+ return last;
301
+ }
302
+
303
+ function escapeRegExp(value) {
304
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
305
+ }
306
+
307
+ /** Reads the project's declared engine version, e.g. "4.5" from config/features. */
308
+ export async function readProjectEngineVersion(projectDir) {
309
+ const text = await readIfPresent(path.join(projectDir, 'project.godot'));
310
+ if (!text) return null;
311
+ const match = text.match(/config\/features\s*=\s*PackedStringArray\(([^)]*)\)/);
312
+ if (!match) return null;
313
+ for (const raw of match[1].split(',')) {
314
+ const value = raw.trim().replace(/^"|"$/g, '');
315
+ if (/^\d+\.\d+$/.test(value)) return value;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /** Parses "4.5.1.stable.official" into a comparable shape. */
321
+ export function parseGodotVersion(output) {
322
+ const match = String(output).match(/(\d+)\.(\d+)(?:\.(\d+))?/);
323
+ if (!match) return null;
324
+ return {
325
+ version: match[0],
326
+ major: Number(match[1]),
327
+ minor: Number(match[2]),
328
+ patch: match[3] === undefined ? null : Number(match[3]),
329
+ };
330
+ }
331
+
332
+ async function defaultProbe(binary) {
333
+ try {
334
+ const { stdout, stderr } = await execFileAsync(binary, ['--headless', '--version'], {
335
+ timeout: 20000,
336
+ });
337
+ return parseGodotVersion(`${stdout}\n${stderr}`);
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Finds a usable Godot 4.2+ binary the way spec 17 describes: an explicit
345
+ * path, then $GODOT, then PATH, then the platform's usual install locations.
346
+ * When the project declares an engine version, a binary with that major.minor
347
+ * wins over a newer one.
348
+ *
349
+ * `RAVENSIGHT_GODOT` is read before the generic `$GODOT` spec 17 names. It is
350
+ * the variable the README and `check`'s fix hints tell a developer to set, and
351
+ * a project scoped override should win over a machine wide one: someone whose
352
+ * `$GODOT` points at the 4.2 build they ship with can point this at the 4.5
353
+ * they are testing without disturbing it.
354
+ */
355
+ export async function detectGodotBinary({
356
+ explicitPath,
357
+ env = process.env,
358
+ platform = process.platform,
359
+ home = os.homedir(),
360
+ projectDir,
361
+ probe = defaultProbe,
362
+ minMajor = 4,
363
+ minMinor = 2,
364
+ } = {}) {
365
+ const wanted = projectDir ? await readProjectEngineVersion(projectDir) : null;
366
+ const candidates = [];
367
+ const push = (value) => {
368
+ if (value && !candidates.includes(value)) candidates.push(value);
369
+ };
370
+
371
+ push(explicitPath);
372
+ push(env.RAVENSIGHT_GODOT);
373
+ push(env.GODOT);
374
+ push(env.GODOT_PATH);
375
+ for (const name of ['godot', 'godot4']) {
376
+ for (const dir of (env.PATH ?? '').split(path.delimiter)) {
377
+ if (dir) push(path.join(dir, platform === 'win32' ? `${name}.exe` : name));
378
+ }
379
+ }
380
+ for (const found of await platformCandidates(platform, env, home)) push(found);
381
+
382
+ const usable = [];
383
+ for (const candidate of candidates) {
384
+ if (!(await isExecutable(candidate))) continue;
385
+ const version = await probe(candidate);
386
+ if (!version) continue;
387
+ if (version.major < minMajor) continue;
388
+ if (version.major === minMajor && version.minor < minMinor) continue;
389
+ usable.push({ path: candidate, ...version });
390
+ if (explicitPath && candidate === explicitPath) break;
391
+ }
392
+ if (usable.length === 0) {
393
+ const hint = explicitPath
394
+ ? `--godot-path ${explicitPath} is not a usable Godot ${minMajor}.${minMinor}+ binary`
395
+ : `no Godot ${minMajor}.${minMinor}+ binary found; install Godot or pass --godot-path`;
396
+ throw new Error(hint);
397
+ }
398
+ if (wanted) {
399
+ const [major, minor] = wanted.split('.').map(Number);
400
+ const exact = usable.find((item) => item.major === major && item.minor === minor);
401
+ if (exact) return { ...exact, projectVersion: wanted, matchesProject: true };
402
+ }
403
+ return { ...usable[0], projectVersion: wanted, matchesProject: false };
404
+ }
405
+
406
+ async function platformCandidates(platform, env, home) {
407
+ const found = [];
408
+ if (platform === 'darwin') {
409
+ for (const dir of ['/Applications', path.join(home, 'Applications')]) {
410
+ for (const name of await listDir(dir)) {
411
+ if (/^Godot.*\.app$/.test(name)) {
412
+ found.push(path.join(dir, name, 'Contents/MacOS/Godot'));
413
+ }
414
+ }
415
+ }
416
+ } else if (platform === 'win32') {
417
+ const roots = [env.LOCALAPPDATA, path.join(env.LOCALAPPDATA ?? '', 'Programs'), env.PROGRAMFILES].filter(Boolean);
418
+ for (const root of roots) {
419
+ for (const name of await listDir(root)) {
420
+ if (/^Godot/i.test(name)) {
421
+ if (name.toLowerCase().endsWith('.exe')) found.push(path.join(root, name));
422
+ for (const inner of await listDir(path.join(root, name))) {
423
+ if (/^Godot.*\.exe$/i.test(inner)) found.push(path.join(root, name, inner));
424
+ }
425
+ }
426
+ }
427
+ }
428
+ } else {
429
+ for (const dir of [path.join(home, '.local/bin'), '/usr/local/bin', '/usr/bin', '/opt/godot']) {
430
+ for (const name of await listDir(dir)) {
431
+ if (/^godot(4|-4.*)?$/.test(name)) found.push(path.join(dir, name));
432
+ }
433
+ }
434
+ }
435
+ return found;
436
+ }
437
+
438
+ async function listDir(dir) {
439
+ if (!dir) return [];
440
+ try {
441
+ return await fs.readdir(dir);
442
+ } catch {
443
+ return [];
444
+ }
445
+ }
446
+
447
+ async function isExecutable(file) {
448
+ try {
449
+ const stat = await fs.stat(file);
450
+ if (!stat.isFile()) return false;
451
+ await fs.access(file, fs.constants.X_OK);
452
+ return true;
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+
458
+ /** Finds a loopback port the driver can bind, starting from the spec default. */
459
+ export async function findFreePort(start = 47800, tries = 64) {
460
+ for (let offset = 0; offset < tries; offset += 1) {
461
+ const port = start + offset;
462
+ if (await canBind(port)) return port;
463
+ }
464
+ throw new Error(`no free loopback port in ${start}..${start + tries - 1}`);
465
+ }
466
+
467
+ function canBind(port) {
468
+ return new Promise((resolve) => {
469
+ const server = net.createServer();
470
+ server.once('error', () => resolve(false));
471
+ server.listen(port, '127.0.0.1', () => {
472
+ server.close(() => resolve(true));
473
+ });
474
+ });
475
+ }