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
@@ -0,0 +1,95 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Whether a Godot project is 2D or 3D, and what the 3D Alpha says about it.
6
+ *
7
+ * Labs beta is Godot 2D. The Alpha is a proof of concept: one template, Build a
8
+ * feature and Validate changes, each asked for by name with `--alpha-3d`. It adds no
9
+ * switch and no price: the game's Labs opt-in and the capability's own LABS_* switch
10
+ * still decide, and the job is priced exactly as a 2D one is.
11
+ */
12
+
13
+ export const DIMENSION_2D = '2d';
14
+ export const DIMENSION_3D = '3d';
15
+
16
+ /**
17
+ * The type of the main scene's root node, which is what decides what a player sees.
18
+ *
19
+ * @param {string} projectDir
20
+ * @returns {Promise<string|null>} null when there is no readable main scene or root
21
+ */
22
+ export async function mainSceneRoot(projectDir) {
23
+ const settings = await readFile(path.join(projectDir, 'project.godot'), 'utf8').catch(() => null);
24
+ if (settings === null) return null;
25
+ const main = /^run\/main_scene\s*=\s*"([^"]+)"/m.exec(settings);
26
+ if (!main) return null;
27
+ const scene = await sceneText(projectDir, main[1]);
28
+ const root = scene && /\[node name="[^"]*" type="([A-Za-z0-9_]+)"/.exec(scene);
29
+ return root ? root[1] : null;
30
+ }
31
+
32
+ async function sceneText(projectDir, reference) {
33
+ if (reference.startsWith('res://')) {
34
+ return readFile(path.join(projectDir, reference.slice('res://'.length)), 'utf8').catch(() => null);
35
+ }
36
+ if (!reference.startsWith('uid://')) return null;
37
+ const stack = [projectDir];
38
+ let seen = 0;
39
+ while (stack.length > 0 && seen < 2000) {
40
+ const directory = stack.pop();
41
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
42
+ for (const entry of entries) {
43
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
44
+ const full = path.join(directory, entry.name);
45
+ if (entry.isDirectory()) stack.push(full);
46
+ else if (entry.name.endsWith('.tscn')) {
47
+ seen += 1;
48
+ const text = await readFile(full, 'utf8').catch(() => '');
49
+ if (text.slice(0, 300).includes(`uid="${reference}"`)) return text;
50
+ }
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Whether this root type is a 3D one: Node3D, CharacterBody3D and so on. */
57
+ export function isThreeDRoot(root) {
58
+ return typeof root === 'string' && /3D$/.test(root);
59
+ }
60
+
61
+ /**
62
+ * Why this command may not run on this project with these flags, or null. A free
63
+ * answer, before any price.
64
+ *
65
+ * @param {{projectDir: string, alpha3d?: boolean, name: string}} input `name` is the capability, for the sentence
66
+ * @returns {Promise<{refusal: string|null, dimension: string}>}
67
+ */
68
+ export async function dimensionGate({ projectDir, alpha3d = false, name }) {
69
+ const root = await mainSceneRoot(projectDir);
70
+ const threeD = isThreeDRoot(root);
71
+ if (threeD && !alpha3d) {
72
+ return { dimension: DIMENSION_3D, refusal: `The main scene's root is a ${root}. ${name} works on 2D Godot projects in the beta. 3D is an Alpha proof of concept: pass --alpha-3d to ask for it.` };
73
+ }
74
+ if (!threeD && alpha3d) {
75
+ return { dimension: DIMENSION_2D, refusal: `--alpha-3d is for a project whose main scene has a 3D root, and this one's is ${root ? `a ${root}` : 'not one'}. Leave the flag off for a 2D project.` };
76
+ }
77
+ return { dimension: threeD ? DIMENSION_3D : DIMENSION_2D, refusal: null };
78
+ }
79
+
80
+ /**
81
+ * Whether a brief for Create a game asks for 3D. A brief that says 2D as well goes on,
82
+ * because "2D, not 3D" is a 2D brief, and the design step can still decline it.
83
+ *
84
+ * @param {string} brief
85
+ * @returns {boolean}
86
+ */
87
+ export function briefAsksFor3d(brief) {
88
+ const text = String(brief || '');
89
+ return /\b(?:3-?d|three[- ]dimensional)\b/i.test(text) && !/\b(?:2-?d|two[- ]dimensional)\b/i.test(text);
90
+ }
91
+
92
+ /** What create says to a 3D request. Plain, and free. */
93
+ export const CREATE_REFUSES_3D = 'Create a game makes 2D Godot games only, and it does not make a 3D game from a brief. Nothing was charged. The 3D Alpha is a proof of concept that starts from a template you run yourself: ravensight-playtest template godot-3d --game <gameId> --dir <path>. Build a feature and Validate changes then work on it with --alpha-3d.';
94
+
95
+ export default { mainSceneRoot, isThreeDRoot, dimensionGate, briefAsksFor3d, CREATE_REFUSES_3D, DIMENSION_2D, DIMENSION_3D };
@@ -11,6 +11,9 @@ import { createHash } from 'node:crypto';
11
11
 
12
12
  export const MAX_TEXT_CHARS = 12000;
13
13
 
14
+ /** Shown only when a node has three coordinates, so a 2D observation never changes. */
15
+ export const NOTE_3D = 'note: this scene is 3D. @(x,y,z) is a world position, not a place on the screen: to click one of those nodes, name its path, and never pass its numbers as x and y. Keys and input actions move the player.';
16
+
14
17
  const PRESSABLE = /Button|LineEdit|TextEdit|Slider|CheckBox|CheckButton|OptionButton|ItemList|Tree/;
15
18
 
16
19
  /**
@@ -38,6 +41,7 @@ export function buildObservation({
38
41
  if (state?.current_scene) lines.push(`scene node: ${state.current_scene}`);
39
42
  if (collected.scripts.length > 0) lines.push(`scripts: ${collected.scripts.join(', ')}`);
40
43
  if (state?.truncated) lines.push('note: the scene tree was truncated');
44
+ if (kept && hasPosition3d(kept)) lines.push(NOTE_3D);
41
45
  if (lines.length > 0) lines.push('');
42
46
 
43
47
  if (kept) {
@@ -46,16 +50,12 @@ export function buildObservation({
46
50
  lines.push('(no visible nodes)');
47
51
  }
48
52
 
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);
53
+ // The game state is what a persona plays from (ember, room, position) and
54
+ // the first thing a long scene tree used to push past the cap, so it is
55
+ // bounded on its own and the tree is cut to make room for it.
56
+ const gameStateText = gameStateSummary(gameState);
57
+ const stateSection = gameStateText ? `\n\ngame state:\n${gameStateText}` : '';
58
+ const text = truncate(lines.join('\n'), Math.max(0, maxChars - stateSection.length)) + stateSection;
59
59
  const observation = {
60
60
  step,
61
61
  text,
@@ -65,12 +65,50 @@ export function buildObservation({
65
65
  scene: scenePath,
66
66
  scenes: collected.scenes,
67
67
  scripts: collected.scripts,
68
+ gameStateText,
68
69
  hash: createHash('sha1').update(text).digest('hex'),
69
70
  };
70
71
  if (screenshotBase64) observation.screenshot_b64 = screenshotBase64;
71
72
  return observation;
72
73
  }
73
74
 
75
+ /** The most characters of one `_ravensight_state()` dictionary shown. */
76
+ export const MAX_STATE_CHARS = 2000;
77
+
78
+ /** The most characters of the whole game state section. */
79
+ export const MAX_STATE_SECTION_CHARS = 4000;
80
+
81
+ /**
82
+ * Every `_ravensight_state()` result as ` <node path>: <json>` lines, each
83
+ * and all bounded, or '' when the game has no hook.
84
+ *
85
+ * @param {object|null} gameState the driver's get_game_state answer
86
+ * @returns {string}
87
+ */
88
+ export function gameStateSummary(gameState) {
89
+ const states = gameState?.states ?? {};
90
+ const lines = [];
91
+ let used = 0;
92
+ for (const key of Object.keys(states)) {
93
+ let json;
94
+ try {
95
+ json = JSON.stringify(states[key]);
96
+ } catch {
97
+ json = '"(unserialisable)"';
98
+ }
99
+ if (json === undefined) continue;
100
+ if (json.length > MAX_STATE_CHARS) json = `${json.slice(0, MAX_STATE_CHARS)}... [cut]`;
101
+ const line = ` ${key}: ${json}`;
102
+ if (used + line.length > MAX_STATE_SECTION_CHARS) {
103
+ lines.push(` (${Object.keys(states).length - lines.length} more state node(s) left out)`);
104
+ break;
105
+ }
106
+ lines.push(line);
107
+ used += line.length + 1;
108
+ }
109
+ return lines.join('\n');
110
+ }
111
+
74
112
  /** True when the outline is too thin to play from, so vision is worth the cost. */
75
113
  export function needsVision(observation, { minChars = 200 } = {}) {
76
114
  const body = String(observation?.text ?? '')
@@ -110,7 +148,9 @@ function describe(node) {
110
148
  const parts = [node.type ?? 'Node', node.name ?? ''];
111
149
  if (node.text) parts.push(JSON.stringify(collapse(node.text)));
112
150
  if (Array.isArray(node.position)) {
113
- parts.push(`@(${node.position.map((value) => round(value)).join(',')})`);
151
+ // A 3D unit is a metre, where a 2D one is a pixel, so whole numbers would hide a walk.
152
+ const place = node.position.length === 3 ? round3d : round;
153
+ parts.push(`@(${node.position.map((value) => place(value)).join(',')})`);
114
154
  }
115
155
  const flags = [];
116
156
  if (node.focus) flags.push('focus');
@@ -124,6 +164,14 @@ function describe(node) {
124
164
  return parts.filter(Boolean).join(' ');
125
165
  }
126
166
 
167
+ function hasPosition3d(node) {
168
+ return (Array.isArray(node.position) && node.position.length === 3) || (node.children ?? []).some(hasPosition3d);
169
+ }
170
+
171
+ function round3d(value) {
172
+ return typeof value === 'number' ? Math.round(value * 100) / 100 : value;
173
+ }
174
+
127
175
  function collapse(text) {
128
176
  return String(text).replace(/\s+/g, ' ').trim().slice(0, 200);
129
177
  }
@@ -11,7 +11,7 @@
11
11
  // across every project layout). Both name the same script, so registering both
12
12
  // is idempotent, and one of them is guaranteed to be the mechanism Godot
13
13
  // honors.
14
- import { execFile } from 'node:child_process';
14
+ import { execFile, spawn as spawnProcess } from 'node:child_process';
15
15
  import net from 'node:net';
16
16
  import fs from 'node:fs/promises';
17
17
  import os from 'node:os';
@@ -161,6 +161,43 @@ async function materializePath(root, relativePath) {
161
161
  }
162
162
  }
163
163
 
164
+ /**
165
+ * A real copy of a project, with no link left that points at the developer's files.
166
+ *
167
+ * @param {string} projectDir
168
+ * @param {string} destination
169
+ * @param {{excludes?: Array<string>, skip?: (source: string) => boolean}} [options]
170
+ */
171
+ export async function copyProjectTree(projectDir, destination, { excludes = DEFAULT_EXCLUDES, skip = () => false } = {}) {
172
+ // One dereference of any given directory. measureProject counts a symlinked
173
+ // tree once, so the copy has to copy it once, or the two disagree and the
174
+ // cap stops meaning anything.
175
+ const visited = new Set();
176
+ const realProjectDir = await realRootOf(projectDir);
177
+ // dereference: a symlink copied as a symlink still points at the developer's
178
+ // real file, and every write below would go straight through it.
179
+ await fs.cp(projectDir, destination, {
180
+ recursive: true,
181
+ force: true,
182
+ dereference: true,
183
+ filter: async (source) => {
184
+ if (excludes.includes(path.basename(source)) || skip(source)) return false;
185
+ const link = await fs.lstat(source).catch(() => null);
186
+ // fs.cp refuses a named pipe or a socket outright, and neither is part of a game.
187
+ if (link && (link.isFIFO() || link.isSocket())) return false;
188
+ if (!link?.isSymbolicLink()) return true;
189
+ // fs.cp throws ENOENT on a symlink it cannot dereference, and a project
190
+ // with one broken link in it is not a project that should fail to launch.
191
+ const real = await fs.realpath(source).catch(() => null);
192
+ if (!real) return false;
193
+ if (resolvesIntoOwnAncestry(real, realProjectDir)) return false;
194
+ if (visited.has(real)) return false;
195
+ visited.add(real);
196
+ return true;
197
+ },
198
+ });
199
+ }
200
+
164
201
  /**
165
202
  * Copies the project and injects the driver addon.
166
203
  * Returns the copy's path plus the temp root to delete when the run ends.
@@ -185,31 +222,7 @@ export async function prepareProjectCopy({
185
222
  await fs.mkdir(root, { recursive: true, mode: 0o700 });
186
223
  await fs.chmod(root, 0o700);
187
224
 
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
- });
225
+ await copyProjectTree(projectDir, projectCopy, { excludes });
213
226
 
214
227
  // Belt and braces: dereference covers the copy, materialize covers anything
215
228
  // it left behind, so the three paths written below cannot be links.
@@ -304,6 +317,40 @@ function escapeRegExp(value) {
304
317
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
305
318
  }
306
319
 
320
+ /** Godot's physical key codes for the keys a game commonly binds, by the name a player would use. */
321
+ const NAMED_KEYS = new Map([
322
+ [32, 'Space'], [4194305, 'Escape'], [4194306, 'Tab'], [4194309, 'Enter'], [4194325, 'Shift'], [4194326, 'Control'],
323
+ [4194319, 'ArrowLeft'], [4194320, 'ArrowUp'], [4194321, 'ArrowRight'], [4194322, 'ArrowDown']
324
+ ]);
325
+
326
+ /**
327
+ * The keys each input action in project.godot is bound to, as one line a persona
328
+ * can read, so it presses the key the game listens for and does not guess. Null
329
+ * when the file or its input section cannot be read: a persona can still explore.
330
+ *
331
+ * @param {string} projectDir
332
+ * @returns {Promise<string|null>}
333
+ */
334
+ export async function inputControls(projectDir) {
335
+ let text;
336
+ try {
337
+ text = await fs.readFile(path.join(projectDir, 'project.godot'), 'utf8');
338
+ } catch {
339
+ return null;
340
+ }
341
+ const section = /^\[input\]\n([\s\S]*?)(?=^\[[a-z_]+\]\s*$|(?![\s\S]))/m.exec(text);
342
+ if (!section) return null;
343
+ const lines = [];
344
+ for (const [, action, body] of section[1].matchAll(/^([A-Za-z_][\w.]*)=\{([\s\S]*?)^\}/gm)) {
345
+ const keys = [...body.matchAll(/InputEventKey[^)]*?"(?:physical_keycode|keycode)":\s*(\d+)/g)].map(([, code]) => {
346
+ const number = Number(code);
347
+ return NAMED_KEYS.get(number) || (number > 32 && number < 127 ? String.fromCharCode(number) : null);
348
+ }).filter(Boolean);
349
+ if (keys.length > 0) lines.push(`${action}: ${[...new Set(keys)].join(' or ')}`);
350
+ }
351
+ return lines.length > 0 ? lines.join('; ') : null;
352
+ }
353
+
307
354
  /** Reads the project's declared engine version, e.g. "4.5" from config/features. */
308
355
  export async function readProjectEngineVersion(projectDir) {
309
356
  const text = await readIfPresent(path.join(projectDir, 'project.godot'));
@@ -381,11 +428,14 @@ export async function detectGodotBinary({
381
428
 
382
429
  const usable = [];
383
430
  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;
431
+ const version = (await isExecutable(candidate)) ? await probe(candidate) : null;
432
+ const fits = version && (version.major > minMajor || (version.major === minMajor && version.minor >= minMinor));
433
+ if (!fits) {
434
+ // Somebody named this one. Playing on a different Godot would answer a question
435
+ // they did not ask, so a path that cannot be used is refused, not stepped past.
436
+ if (explicitPath && candidate === explicitPath) throw new Error(`--godot-path ${explicitPath} is not a usable Godot ${minMajor}.${minMinor}+ binary`);
437
+ continue;
438
+ }
389
439
  usable.push({ path: candidate, ...version });
390
440
  if (explicitPath && candidate === explicitPath) break;
391
441
  }
@@ -456,6 +506,28 @@ async function isExecutable(file) {
456
506
  }
457
507
 
458
508
  /** Finds a loopback port the driver can bind, starting from the spec default. */
509
+ /**
510
+ * Two drivers in one process asked "what is free" at once and both heard 47800:
511
+ * the second Godot failed to listen, its CLI dialled the first one's socket with
512
+ * the wrong token, and both runs died (found running two personas at once). So a
513
+ * port handed out here is not handed out again, and each search starts somewhere
514
+ * random in the range so two processes rarely meet either.
515
+ *
516
+ * @returns {Promise<number>}
517
+ */
518
+ const handedOut = new Set();
519
+ export async function reservePort({ base = 47800, span = 1024, find = findFreePort } = {}) {
520
+ for (let attempt = 0; attempt < 16; attempt += 1) {
521
+ const start = base + Math.floor(Math.random() * span);
522
+ const port = await find(start, 64);
523
+ if (!handedOut.has(port)) {
524
+ handedOut.add(port);
525
+ return port;
526
+ }
527
+ }
528
+ return find(base, 64);
529
+ }
530
+
459
531
  export async function findFreePort(start = 47800, tries = 64) {
460
532
  for (let offset = 0; offset < tries; offset += 1) {
461
533
  const port = start + offset;
@@ -473,3 +545,101 @@ function canBind(port) {
473
545
  });
474
546
  });
475
547
  }
548
+
549
+ /** Directories never walked when deciding whether a project needs an import. */
550
+ const IMPORT_WALK_SKIP = new Set(['.godot', '.git', 'node_modules', '.import']);
551
+
552
+ /** The most scripts read when deciding; past this the import simply runs. */
553
+ const IMPORT_WALK_MAX_FILES = 20000;
554
+
555
+ async function listScripts(dir, out = []) {
556
+ let entries;
557
+ try {
558
+ entries = await fs.readdir(dir, { withFileTypes: true });
559
+ } catch {
560
+ return out;
561
+ }
562
+ for (const entry of entries) {
563
+ if (out.length >= IMPORT_WALK_MAX_FILES) return out;
564
+ if (entry.isDirectory()) {
565
+ if (!IMPORT_WALK_SKIP.has(entry.name)) await listScripts(path.join(dir, entry.name), out);
566
+ } else if (entry.name.endsWith('.gd')) {
567
+ out.push(path.join(dir, entry.name));
568
+ }
569
+ }
570
+ return out;
571
+ }
572
+
573
+ /**
574
+ * Whether the project's `.godot/` import cache is missing or older than its
575
+ * scripts. Godot resolves `class_name` from `.godot/global_script_class_cache.cfg`,
576
+ * which is gitignored, so a fresh clone, a CI checkout or a git worktree has
577
+ * none and every script naming a global class fails to parse
578
+ * (`Could not find type "Game"`). Decided on the developer's own directory,
579
+ * which is read and never written.
580
+ *
581
+ * @param {string} projectDir
582
+ * @returns {Promise<{needed: boolean, reason?: string}>}
583
+ */
584
+ export async function projectNeedsImport(projectDir) {
585
+ const cacheDir = path.join(projectDir, '.godot');
586
+ const dir = await fs.stat(cacheDir).catch(() => null);
587
+ if (!dir?.isDirectory()) return { needed: true, reason: 'no .godot import cache' };
588
+ const scripts = await listScripts(projectDir);
589
+ if (scripts.length >= IMPORT_WALK_MAX_FILES) return { needed: true, reason: 'too many scripts to compare; importing to be safe' };
590
+ const cache = await fs.stat(path.join(cacheDir, 'global_script_class_cache.cfg')).catch(() => null);
591
+ if (!cache) {
592
+ for (const file of scripts) {
593
+ const text = await fs.readFile(file, 'utf8').catch(() => '');
594
+ if (/^\s*class_name\s+\w/m.test(text)) return { needed: true, reason: 'class_name scripts and no class cache' };
595
+ }
596
+ return { needed: false };
597
+ }
598
+ for (const file of scripts) {
599
+ const stat = await fs.stat(file).catch(() => null);
600
+ if (stat && stat.mtimeMs > cache.mtimeMs) return { needed: true, reason: 'scripts changed since the last import' };
601
+ }
602
+ return { needed: false };
603
+ }
604
+
605
+ /**
606
+ * `godot --headless --path <copy> --import`, bounded by a timeout. Run on the
607
+ * temp copy, never on the developer's project. Output goes to the run's
608
+ * godot.log ahead of the game's own.
609
+ *
610
+ * @returns {Promise<{ok: boolean, exitCode?: number|null, error?: string}>}
611
+ */
612
+ export function importProjectCopy({ godotPath, projectCopy, env, timeoutMs = 180000, logPath = null, spawnFn = spawnProcess }) {
613
+ return new Promise((resolve) => {
614
+ let child;
615
+ try {
616
+ child = spawnFn(godotPath, ['--headless', '--path', projectCopy, '--import'], {
617
+ cwd: projectCopy,
618
+ env,
619
+ stdio: ['ignore', 'pipe', 'pipe'],
620
+ });
621
+ } catch (error) {
622
+ resolve({ ok: false, error: error.message });
623
+ return;
624
+ }
625
+ let settled = false;
626
+ const done = (result) => {
627
+ if (settled) return;
628
+ settled = true;
629
+ clearTimeout(timer);
630
+ resolve(result);
631
+ };
632
+ if (logPath) {
633
+ for (const stream of [child.stdout, child.stderr]) {
634
+ stream?.on('data', (chunk) => { void fs.appendFile(logPath, chunk).catch(() => {}); });
635
+ }
636
+ }
637
+ const timer = setTimeout(() => {
638
+ done({ ok: false, exitCode: null, error: `the import timed out after ${Math.round(timeoutMs / 1000)} s` });
639
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
640
+ }, timeoutMs);
641
+ timer.unref?.();
642
+ child.on('error', (error) => done({ ok: false, error: error.message }));
643
+ child.on('exit', (code) => done(code === 0 ? { ok: true, exitCode: 0 } : { ok: false, exitCode: code, error: `exit ${code}` }));
644
+ });
645
+ }