summer-engine 3.4.1 → 3.5.1

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 (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +5 -1
  3. package/.codex-plugin/plugin.json +5 -1
  4. package/.cursor-plugin/plugin.json +5 -1
  5. package/.factory-plugin/plugin.json +5 -1
  6. package/CHANGELOG.md +31 -0
  7. package/README.md +5 -1
  8. package/dist/cli/commands/setup.js +24 -4
  9. package/dist/core/capabilities/capture-gameplay.d.ts +72 -7
  10. package/dist/core/capabilities/capture-gameplay.js +386 -25
  11. package/dist/core/capabilities/export-engine-version.d.ts +7 -0
  12. package/dist/core/capabilities/export-engine-version.js +9 -0
  13. package/dist/core/capabilities/export-game.d.ts +4 -0
  14. package/dist/core/capabilities/export-game.js +50 -7
  15. package/dist/core/capabilities/image-generation.d.ts +12 -2
  16. package/dist/core/capabilities/image-generation.js +31 -2
  17. package/dist/core/capabilities/publish-build.d.ts +4 -0
  18. package/dist/core/capabilities/publish-build.js +84 -37
  19. package/dist/core/capabilities/runtime-control.d.ts +2 -2
  20. package/dist/core/capabilities/summer-bundle.d.ts +9 -0
  21. package/dist/core/capabilities/summer-bundle.js +19 -0
  22. package/dist/core/capabilities/tool-dispatch.js +13 -2
  23. package/dist/core/library-read.d.ts +7 -0
  24. package/dist/core/library-read.js +59 -7
  25. package/dist/core/library-search.d.ts +2 -0
  26. package/dist/installer/agent-config.d.ts +26 -1
  27. package/dist/installer/agent-config.js +180 -5
  28. package/dist/installer/agent-table.d.ts +13 -0
  29. package/dist/installer/agent-table.js +120 -0
  30. package/dist/mcp/tools/creator-tools.js +32 -3
  31. package/dist/mcp/tools/generate-tools.js +11 -30
  32. package/dist/mcp/tools/library-tools.d.ts +2 -1
  33. package/dist/mcp/tools/library-tools.js +1 -0
  34. package/gemini-extension.json +5 -1
  35. package/integrations/README.md +6 -0
  36. package/library/references/mcp-tools-reference/mcp-tools-reference.md +1 -1
  37. package/library/skills/README.md +11 -1
  38. package/library/skills/export-and-ship/SKILL.md +1 -1
  39. package/library/skills/export-and-ship/resource.yaml +2 -1
  40. package/library/skills/grow-analytics/SKILL.md +21 -0
  41. package/library/skills/grow-analytics/resource.yaml +27 -0
  42. package/library/skills/publish-your-game/SKILL.md +21 -0
  43. package/library/skills/publish-your-game/resource.yaml +29 -0
  44. package/library/skills/store-art/SKILL.md +23 -0
  45. package/library/skills/store-art/resource.yaml +30 -0
  46. package/library/skills/store-listing/SKILL.md +21 -0
  47. package/library/skills/store-listing/resource.yaml +25 -0
  48. package/library/skills/summer-analytics/resource.yaml +1 -0
  49. package/library/tools/capture-gameplay/resource.yaml +66 -1
  50. package/library/tools/export-game/resource.yaml +3 -0
  51. package/library/tools/generate-image/resource.yaml +4 -2
  52. package/package.json +1 -1
  53. package/registry/generated/counts.json +2 -2
  54. package/registry/generated/gemini-extension.json +5 -1
  55. package/registry/generated/index.json +153 -10
  56. package/registry/generated/marketplace.claude.json +1 -1
  57. package/registry/generated/plugin.claude.json +5 -1
  58. package/registry/generated/plugin.codex.json +5 -1
  59. package/registry/generated/plugin.cursor.json +5 -1
  60. package/registry/generated/plugin.factory.json +5 -1
  61. package/registry/generated/skills-index.md +11 -1
  62. package/registry/generated/skills-registry.json +52 -0
  63. package/registry/schemas/skill.schema.json +5 -0
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, openSync, readdirSync, readFileSync, readSync, closeSync, statSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import { platform } from "node:os";
4
5
  import { isAbsolute, join, resolve } from "node:path";
@@ -15,8 +16,22 @@ const DEFAULT_RESOLUTION = "1920x1080";
15
16
  const MAX_FRAMES = 10;
16
17
  /** The engine clamps --summer-verify-max to 240 (main.cpp SUMMER_VERIFY_MAX_SECONDS). */
17
18
  const ENGINE_MAX_SECONDS = 240;
19
+ /** Offscreen runs draw uncapped (~130 fps) while the verify backstop counts 60 frames a second; cap the frame rate so seconds stay seconds. */
20
+ const CAPTURE_MAX_FPS = 60;
21
+ /** Files the engine imports; a new or changed one means the .godot/ caches are stale. */
22
+ const IMPORTED_EXTENSIONS = new Set([
23
+ ".png", ".jpg", ".jpeg", ".webp", ".svg", ".tga", ".bmp", ".exr", ".hdr", ".ktx", ".dds",
24
+ ".glb", ".gltf", ".fbx", ".obj", ".blend", ".dae",
25
+ ".wav", ".ogg", ".mp3",
26
+ ".ttf", ".otf", ".woff", ".woff2", ".fnt", ".csv",
27
+ ]);
28
+ const SKIPPED_DIRS = new Set([".godot", ".git", ".summer", ".import", "node_modules", "build", "exports"]);
29
+ const MAX_SCANNED_FILES = 50_000;
18
30
  const IMPORT_TIMEOUT_MS = 5 * 60_000;
19
31
  const PROBE_FILE = "capture_probe.gd";
32
+ const MAX_STEPS = 50;
33
+ const MAX_ARGS = 32;
34
+ const STEP_KINDS = ["press", "key", "action", "click", "drag", "wait", "shot"];
20
35
  function invalid(message, recovery) {
21
36
  return new BuildToolError("capture_args_invalid", message, recovery);
22
37
  }
@@ -54,6 +69,169 @@ function checkScene(project, scene) {
54
69
  }
55
70
  return value;
56
71
  }
72
+ function checkArgs(args) {
73
+ if (args === undefined)
74
+ return [];
75
+ if (!Array.isArray(args) || args.length > MAX_ARGS || args.some((arg) => typeof arg !== "string" || arg.length > 200 || /[\u0000\n\r]/.test(arg))) {
76
+ throw invalid(`args must be at most ${MAX_ARGS} single-line strings.`, 'Recovery: pass the game\'s own flags, e.g. ["--autostart", "--solo"].');
77
+ }
78
+ return args;
79
+ }
80
+ function point(value, where) {
81
+ if (!Array.isArray(value) || value.length !== 2 || !value.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 7680)) {
82
+ throw invalid(`${where} must be [x, y] in frame pixels.`, "Recovery: pass two numbers from 0 to 7680, e.g. [960, 540] for the middle of a 1920x1080 frame.");
83
+ }
84
+ return [value[0], value[1]];
85
+ }
86
+ /** Validate the steps and turn them into the probe's one shape. */
87
+ export function probeSteps(steps) {
88
+ if (steps === undefined)
89
+ return [];
90
+ if (!Array.isArray(steps) || steps.length > MAX_STEPS) {
91
+ throw invalid(`steps must be a list of at most ${MAX_STEPS} steps.`, 'Recovery: pass e.g. [{"press":"Play"},{"wait":2000},{"shot":true}].');
92
+ }
93
+ const out = steps.map((raw, index) => {
94
+ const where = `steps[${index}]`;
95
+ const step = (raw && typeof raw === "object" ? raw : {});
96
+ const kinds = STEP_KINDS.filter((kind) => kind in step);
97
+ if (kinds.length !== 1) {
98
+ throw invalid(`${where} must have exactly one of ${STEP_KINDS.join(", ")}.`, 'Recovery: one step per object, e.g. {"press":"Play"} or {"key":"Space"}.');
99
+ }
100
+ const hold = boundedNumber(step.holdMs, 100, 0, 10_000, `${where}.holdMs`);
101
+ const name = (key) => {
102
+ const value = step[key];
103
+ if (typeof value !== "string" || !value.trim() || value.length > 100) {
104
+ throw invalid(`${where}.${key} must be a non-empty name.`, 'Recovery: e.g. {"press":"Play"}, {"key":"Space"}, {"action":"jump"}.');
105
+ }
106
+ return value.trim();
107
+ };
108
+ switch (kinds[0]) {
109
+ case "press":
110
+ return { type: "press", text: name("press"), timeout: boundedNumber(step.timeoutSeconds, 10, 0, 60, `${where}.timeoutSeconds`) };
111
+ case "key":
112
+ return { type: "key", text: name("key"), hold_ms: hold };
113
+ case "action":
114
+ return { type: "action", text: name("action"), hold_ms: hold };
115
+ case "click":
116
+ return { type: "click", at: point(step.click, `${where}.click`) };
117
+ case "drag": {
118
+ const drag = (step.drag && typeof step.drag === "object" ? step.drag : {});
119
+ return {
120
+ type: "drag",
121
+ at: point(drag.from, `${where}.drag.from`),
122
+ to: point(drag.to, `${where}.drag.to`),
123
+ ms: boundedNumber(drag.ms, 300, 0, 10_000, `${where}.drag.ms`),
124
+ };
125
+ }
126
+ case "wait":
127
+ return { type: "wait", ms: boundedNumber(step.wait, 0, 0, 60_000, `${where}.wait`) };
128
+ default:
129
+ if (step.shot !== true)
130
+ throw invalid(`${where}.shot must be true.`, 'Recovery: {"shot":true} saves a frame at that point.');
131
+ return { type: "shot" };
132
+ }
133
+ });
134
+ if (out.filter((step) => step.type === "shot").length > MAX_FRAMES) {
135
+ throw invalid(`at most ${MAX_FRAMES} shot steps.`, "Recovery: capture again for more frames.");
136
+ }
137
+ return out;
138
+ }
139
+ /** Seconds the steps can take at most (press waits for its button). */
140
+ function stepSeconds(steps) {
141
+ return steps.reduce((sum, step) => sum + (step.timeout ?? 0) + ((step.hold_ms ?? 0) + (step.ms ?? 0)) / 1000 + 0.5, 0);
142
+ }
143
+ /**
144
+ * Why one imported file needs importing again, or null when its import is
145
+ * current. Like the editor: the .import sidecar names the cached result, whose
146
+ * .md5 records the source's hash; the hash is only computed when the source is
147
+ * newer than that record.
148
+ */
149
+ function staleImport(project, path) {
150
+ const relative = path.slice(project.length + 1);
151
+ let sidecar;
152
+ try {
153
+ sidecar = readFileSync(`${path}.import`, "utf8");
154
+ }
155
+ catch {
156
+ return `${relative} has not been imported`;
157
+ }
158
+ const dest = /^path(?:\.[a-z0-9_]+)?="res:\/\/\.godot\/imported\/([^"]+)"/m.exec(sidecar)?.[1];
159
+ // importer "keep" or "skip": nothing is cached.
160
+ if (!dest)
161
+ return null;
162
+ const record = join(project, ".godot", "imported", dest.replace(/\.[^.]+$/, ".md5"));
163
+ let recorded;
164
+ try {
165
+ recorded = { text: readFileSync(record, "utf8"), mtimeMs: statSync(record).mtimeMs };
166
+ }
167
+ catch {
168
+ return `${relative} has no import cache`;
169
+ }
170
+ if (statSync(path).mtimeMs <= recorded.mtimeMs)
171
+ return null;
172
+ const sourceMd5 = /source_md5="([0-9a-f]+)"/.exec(recorded.text)?.[1];
173
+ return sourceMd5 === createHash("md5").update(readFileSync(path)).digest("hex") ? null : `${relative} changed after the last import`;
174
+ }
175
+ function isLfsPointer(path) {
176
+ try {
177
+ if (statSync(path).size > 1024)
178
+ return false;
179
+ const fd = openSync(path, "r");
180
+ const buffer = Buffer.alloc(64);
181
+ readSync(fd, buffer, 0, 64, 0);
182
+ closeSync(fd);
183
+ return buffer.toString("utf8").startsWith("version https://git-lfs");
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
189
+ /**
190
+ * Whether the project's import caches are stale: an imported file (texture,
191
+ * model, sound, font) that has no .import sidecar or cache yet, or whose
192
+ * content changed since it was imported (a pull, or LFS files fetched after
193
+ * the first import). Also names files that are still Git LFS pointers, which no
194
+ * import can fix.
195
+ */
196
+ export function importState(project) {
197
+ if (!existsSync(join(project, ".godot")))
198
+ return { needed: true, reason: "the project was never imported", lfsPointers: [] };
199
+ const lfsPointers = [];
200
+ let reason;
201
+ let scanned = 0;
202
+ const walk = (dir) => {
203
+ let entries;
204
+ try {
205
+ entries = readdirSync(dir, { withFileTypes: true });
206
+ }
207
+ catch {
208
+ return;
209
+ }
210
+ for (const entry of entries) {
211
+ if (scanned >= MAX_SCANNED_FILES)
212
+ return;
213
+ const path = join(dir, entry.name);
214
+ if (entry.isDirectory()) {
215
+ if (!SKIPPED_DIRS.has(entry.name) && !existsSync(join(path, ".gdignore")))
216
+ walk(path);
217
+ continue;
218
+ }
219
+ const dot = entry.name.lastIndexOf(".");
220
+ if (!entry.isFile() || dot < 0 || !IMPORTED_EXTENSIONS.has(entry.name.slice(dot).toLowerCase()))
221
+ continue;
222
+ scanned++;
223
+ if (isLfsPointer(path)) {
224
+ if (lfsPointers.length < 10)
225
+ lfsPointers.push(path.slice(project.length + 1));
226
+ continue;
227
+ }
228
+ if (!reason)
229
+ reason = staleImport(project, path) ?? undefined;
230
+ }
231
+ };
232
+ walk(project);
233
+ return { needed: reason !== undefined, ...(reason ? { reason } : {}), lfsPointers };
234
+ }
57
235
  function stamp(date) {
58
236
  return date.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z").replace("T", "-");
59
237
  }
@@ -77,14 +255,42 @@ export function pngSize(bytes) {
77
255
  return null;
78
256
  return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
79
257
  }
258
+ /** A GDScript string literal (JSON escapes are valid GDScript escapes). */
259
+ const gdString = (text) => JSON.stringify(text);
260
+ const gdFloat = (value) => (Number.isInteger(value) ? `${value}.0` : String(value));
261
+ const gdVector = (point) => `Vector2(${gdFloat(point[0])}, ${gdFloat(point[1])})`;
262
+ /** One step as a typed GDScript call: no Variant reads, so strict projects load it. */
263
+ function stepLine(step, index) {
264
+ switch (step.type) {
265
+ case "press":
266
+ return `await _press(${index}, ${gdString(step.text)}, ${gdFloat(step.timeout)})`;
267
+ case "key":
268
+ return `await _key_named(${index}, ${gdString(step.text)}, ${step.hold_ms})`;
269
+ case "action":
270
+ return `await _action_named(${index}, ${gdString(step.text)}, ${step.hold_ms})`;
271
+ case "click":
272
+ return `await _click(${gdVector(step.at)})`;
273
+ case "drag":
274
+ return `await _drag(${gdVector(step.at)}, ${gdVector(step.to)}, ${step.ms})`;
275
+ case "wait":
276
+ return `await get_tree().create_timer(${gdFloat(step.ms / 1000)}, true).timeout`;
277
+ default:
278
+ return "await _shot()";
279
+ }
280
+ }
80
281
  /**
81
282
  * The stock probe. Self-contained (extends Node, no base class to copy into the
82
283
  * project); follows the verify-instance contract of assets/autopilot/probe_base.gd:
83
284
  * the engine sets summer_out_dir and summer_max_seconds before _ready(), the
84
285
  * first frames are empty until the scene has drawn twice, results.json is
85
- * always written.
286
+ * always written. The steps are generated as typed calls, and nothing reads a
287
+ * Variant, so projects that treat every GDScript warning as an error
288
+ * (untyped_declaration, unsafe_call_argument, unsafe_cast, integer_division)
289
+ * still load it.
86
290
  */
87
- export function captureProbeSource(frames, waitSeconds, intervalSeconds) {
291
+ export function captureProbeSource(frames, waitSeconds, intervalSeconds, steps = []) {
292
+ const shotSteps = steps.some((step) => step.type === "shot");
293
+ const body = steps.length ? steps.map((step, index) => `\t${stepLine(step, index)}`).join("\n") : "\tpass";
88
294
  return `# Written by summer_capture_gameplay. Safe to delete.
89
295
  # Fully typed: projects that treat GDScript warnings as errors must still load it.
90
296
  extends Node
@@ -95,11 +301,13 @@ var summer_max_seconds: int = 20
95
301
  const FRAMES: int = ${frames}
96
302
  const WAIT_SECONDS: float = ${waitSeconds.toFixed(3)}
97
303
  const INTERVAL_SECONDS: float = ${intervalSeconds.toFixed(3)}
304
+ const SHOT_STEPS: bool = ${shotSteps}
98
305
 
99
- var _shots: Array = []
100
- var _warnings: Array = []
306
+ var _shots: Array[Dictionary] = []
307
+ var _warnings: Array[String] = []
101
308
  var _start_ms: int = 0
102
309
  var _done: bool = false
310
+ var _pressed_seen: bool = false
103
311
 
104
312
  func _ready() -> void:
105
313
  \tprocess_mode = Node.PROCESS_MODE_ALWAYS
@@ -121,23 +329,159 @@ func _run() -> void:
121
329
  \t\t_finish(true)
122
330
  \t\treturn
123
331
  \tawait get_tree().create_timer(WAIT_SECONDS, true).timeout
124
- \tfor i: int in range(FRAMES):
125
- \t\tif i > 0:
126
- \t\t\tawait get_tree().create_timer(INTERVAL_SECONDS, true).timeout
127
- \t\tawait RenderingServer.frame_post_draw
128
- \t\tvar img: Image = get_viewport().get_texture().get_image()
129
- \t\tvar file_name: String = "shot-%02d.png" % (i + 1)
130
- \t\tif img == null:
131
- \t\t\t_warnings.append("%s: the viewport returned no image." % file_name)
132
- \t\t\tcontinue
133
- \t\tif img.save_png(summer_out_dir.path_join(file_name)) == OK:
134
- \t\t\t_shots.append({"file": file_name, "width": img.get_width(), "height": img.get_height()})
135
- \t\telse:
136
- \t\t\t_warnings.append("%s: could not write the PNG." % file_name)
332
+ \tawait _steps()
333
+ \tif not SHOT_STEPS:
334
+ \t\tfor i: int in range(FRAMES):
335
+ \t\t\tif i > 0:
336
+ \t\t\t\tawait get_tree().create_timer(INTERVAL_SECONDS, true).timeout
337
+ \t\t\tawait _shot()
137
338
  \t_finish(true)
138
339
 
139
- func _errors() -> Array:
140
- \tvar errors: Array = []
340
+ func _steps() -> void:
341
+ ${body}
342
+
343
+ func _shot() -> void:
344
+ \tawait RenderingServer.frame_post_draw
345
+ \tvar img: Image = get_viewport().get_texture().get_image()
346
+ \tvar file_name: String = "shot-%02d.png" % (_shots.size() + 1)
347
+ \tif img == null:
348
+ \t\t_warnings.append("%s: the viewport returned no image." % file_name)
349
+ \t\treturn
350
+ \tif img.save_png(summer_out_dir.path_join(file_name)) == OK:
351
+ \t\t_shots.append({"file": file_name, "width": img.get_width(), "height": img.get_height()})
352
+ \telse:
353
+ \t\t_warnings.append("%s: could not write the PNG." % file_name)
354
+
355
+ # Clicks and drags take frame pixels: the saved PNG's own coordinates. The frame
356
+ # is the root viewport; the final transform maps it to the window, where input
357
+ # events land, so a stretched canvas still gets the click where the frame shows it.
358
+ func _to_window(frame_pos: Vector2) -> Vector2:
359
+ \treturn get_viewport().get_final_transform() * frame_pos
360
+
361
+ func _mouse_button(frame_pos: Vector2, pressed: bool) -> void:
362
+ \tvar ev: InputEventMouseButton = InputEventMouseButton.new()
363
+ \tev.button_index = MOUSE_BUTTON_LEFT
364
+ \tev.pressed = pressed
365
+ \tev.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0
366
+ \tev.position = _to_window(frame_pos)
367
+ \tev.global_position = ev.position
368
+ \tInput.parse_input_event(ev)
369
+
370
+ func _mouse_move(frame_pos: Vector2, relative: Vector2, held: bool) -> void:
371
+ \tvar ev: InputEventMouseMotion = InputEventMouseMotion.new()
372
+ \tev.position = _to_window(frame_pos)
373
+ \tev.global_position = ev.position
374
+ \tev.relative = relative
375
+ \tev.button_mask = MOUSE_BUTTON_MASK_LEFT if held else 0
376
+ \tInput.parse_input_event(ev)
377
+
378
+ func _click(frame_pos: Vector2) -> void:
379
+ \t_mouse_move(frame_pos, Vector2.ZERO, false)
380
+ \tawait get_tree().process_frame
381
+ \t_mouse_button(frame_pos, true)
382
+ \tawait get_tree().create_timer(0.05, true).timeout
383
+ \t_mouse_button(frame_pos, false)
384
+ \tawait get_tree().process_frame
385
+
386
+ func _drag(from: Vector2, to: Vector2, ms: int) -> void:
387
+ \t_mouse_move(from, Vector2.ZERO, false)
388
+ \t_mouse_button(from, true)
389
+ \tvar moves: int = maxi(2, roundi(ms / 16.0))
390
+ \tvar last: Vector2 = from
391
+ \tfor j: int in range(1, moves + 1):
392
+ \t\tawait get_tree().create_timer(ms / 1000.0 / moves, true).timeout
393
+ \t\tvar here: Vector2 = from.lerp(to, float(j) / moves)
394
+ \t\t_mouse_move(here, here - last, true)
395
+ \t\tlast = here
396
+ \t_mouse_button(to, false)
397
+ \tawait get_tree().process_frame
398
+
399
+ func _key_named(index: int, key_name: String, hold_ms: int) -> void:
400
+ \tvar keycode: Key = OS.find_keycode_from_string(key_name)
401
+ \tif keycode == KEY_NONE:
402
+ \t\t_warnings.append('steps[%d]: no key is named "%s" (use names like Space, Enter, Escape, A, Up).' % [index, key_name])
403
+ \t\treturn
404
+ \tvar ev: InputEventKey = InputEventKey.new()
405
+ \tev.keycode = keycode
406
+ \tev.physical_keycode = keycode
407
+ \tev.pressed = true
408
+ \tInput.parse_input_event(ev)
409
+ \tawait get_tree().create_timer(hold_ms / 1000.0, true).timeout
410
+ \tvar up: InputEventKey = InputEventKey.new()
411
+ \tup.keycode = keycode
412
+ \tup.physical_keycode = keycode
413
+ \tup.pressed = false
414
+ \tInput.parse_input_event(up)
415
+ \tawait get_tree().process_frame
416
+
417
+ func _action_named(index: int, action: String, hold_ms: int) -> void:
418
+ \tif not InputMap.has_action(StringName(action)):
419
+ \t\t_warnings.append('steps[%d]: the project has no input action "%s".' % [index, action])
420
+ \t\treturn
421
+ \tvar ev: InputEventAction = InputEventAction.new()
422
+ \tev.action = StringName(action)
423
+ \tev.pressed = true
424
+ \tInput.parse_input_event(ev)
425
+ \tawait get_tree().create_timer(hold_ms / 1000.0, true).timeout
426
+ \tvar up: InputEventAction = InputEventAction.new()
427
+ \tup.action = StringName(action)
428
+ \tup.pressed = false
429
+ \tInput.parse_input_event(up)
430
+ \tawait get_tree().process_frame
431
+
432
+ # A visible, enabled button: exact text, then node name, then partial text, then partial name.
433
+ func _find_button(label: String) -> BaseButton:
434
+ \tvar want: String = label.strip_edges().to_lower()
435
+ \tvar best: BaseButton = null
436
+ \tvar best_rank: int = 99
437
+ \tfor node: Node in get_tree().root.find_children("*", "BaseButton", true, false):
438
+ \t\tvar button: BaseButton = node as BaseButton
439
+ \t\tif button == null or button.disabled or not button.is_visible_in_tree():
440
+ \t\t\tcontinue
441
+ \t\tvar text: String = ""
442
+ \t\tvar as_button: Button = button as Button
443
+ \t\tif as_button != null:
444
+ \t\t\ttext = tr(as_button.text).strip_edges().to_lower()
445
+ \t\tvar node_name: String = String(button.name).to_lower()
446
+ \t\tvar rank: int = 99
447
+ \t\tif text == want:
448
+ \t\t\trank = 0
449
+ \t\telif node_name == want:
450
+ \t\t\trank = 1
451
+ \t\telif text.contains(want):
452
+ \t\t\trank = 2
453
+ \t\telif node_name.contains(want):
454
+ \t\t\trank = 3
455
+ \t\tif rank < best_rank:
456
+ \t\t\tbest = button
457
+ \t\t\tbest_rank = rank
458
+ \treturn best
459
+
460
+ func _on_pressed_seen() -> void:
461
+ \t_pressed_seen = true
462
+
463
+ # Click the button's centre like a player; emit pressed when the click did not reach it.
464
+ func _press(index: int, label: String, timeout: float) -> void:
465
+ \tvar button: BaseButton = _find_button(label)
466
+ \tvar deadline: int = Time.get_ticks_msec() + roundi(timeout * 1000.0)
467
+ \twhile button == null and Time.get_ticks_msec() < deadline:
468
+ \t\tawait get_tree().create_timer(0.1, true).timeout
469
+ \t\tbutton = _find_button(label)
470
+ \tif button == null:
471
+ \t\t_warnings.append('steps[%d]: no visible, enabled button "%s" within %.1f s.' % [index, label, timeout])
472
+ \t\treturn
473
+ \t_pressed_seen = false
474
+ \tbutton.pressed.connect(_on_pressed_seen, CONNECT_ONE_SHOT)
475
+ \tvar centre: Vector2 = button.get_global_transform_with_canvas() * (button.size / 2.0)
476
+ \tawait _click(centre)
477
+ \tif not _pressed_seen and is_instance_valid(button):
478
+ \t\tif button.pressed.is_connected(_on_pressed_seen):
479
+ \t\t\tbutton.pressed.disconnect(_on_pressed_seen)
480
+ \t\tbutton.pressed.emit()
481
+ \tawait get_tree().process_frame
482
+
483
+ func _errors() -> Array[String]:
484
+ \tvar errors: Array[String] = []
141
485
  \tvar path: String = summer_out_dir.path_join("errors.log")
142
486
  \tif FileAccess.file_exists(path):
143
487
  \t\tvar f: FileAccess = FileAccess.open(path, FileAccess.READ)
@@ -171,6 +515,8 @@ export async function captureGameplay(input, overrides = {}) {
171
515
  const waitSeconds = boundedNumber(input.waitSeconds, 3, 0, 120, "waitSeconds");
172
516
  const intervalSeconds = boundedNumber(input.intervalSeconds, 1, 0.1, 60, "intervalSeconds");
173
517
  const scene = checkScene(project, input.scene);
518
+ const userArgs = checkArgs(input.args);
519
+ const steps = probeSteps(input.steps);
174
520
  const started = deps.now();
175
521
  const binary = deps.findBinary();
176
522
  if (!binary) {
@@ -185,19 +531,25 @@ export async function captureGameplay(input, overrides = {}) {
185
531
  }
186
532
  await mkdir(out, { recursive: true });
187
533
  await rm(join(out, "results.json"), { force: true });
188
- // A never-opened checkout has no .godot/ caches; the game would boot into
189
- // missing-import errors. Build them once, headless (same as tests/autopilot).
534
+ // A never-opened checkout has no .godot/ caches, and a pull (or LFS files
535
+ // fetched later) leaves them stale; the game would boot into missing or old
536
+ // imports. Import headless first (same as tests/autopilot).
190
537
  let imported = false;
191
- if (!existsSync(join(project, ".godot"))) {
538
+ const importCheck = importState(project);
539
+ if (importCheck.needed) {
192
540
  const run = await deps.run(binary, ["--headless", "--import", "--disable-crash-handler", "--path", project], IMPORT_TIMEOUT_MS);
193
541
  if (run.timedOut || !existsSync(join(project, ".godot"))) {
194
542
  throw new BuildToolError("import_failed", "Summer Engine could not import the project's assets before the capture.", "Recovery: open the project once in Summer Engine (or run the engine with --headless --import), then capture again.", undefined, { output: outputTail(run.output) });
195
543
  }
196
544
  imported = true;
197
545
  }
546
+ const importReason = importCheck.reason;
198
547
  const probe = join(out, PROBE_FILE);
199
- await writeFile(probe, captureProbeSource(frames, waitSeconds, intervalSeconds));
200
- const maxSeconds = Math.min(ENGINE_MAX_SECONDS, Math.ceil(waitSeconds + (frames - 1) * intervalSeconds + 30));
548
+ await writeFile(probe, captureProbeSource(frames, waitSeconds, intervalSeconds, steps));
549
+ // The engine's verify backstop counts frames, not seconds; with the frame rate
550
+ // capped at 60 twice the planned run leaves room for slow loads.
551
+ const plannedSeconds = waitSeconds + (frames - 1) * intervalSeconds + stepSeconds(steps) + 15;
552
+ const maxSeconds = Math.min(ENGINE_MAX_SECONDS, Math.ceil(2 * plannedSeconds));
201
553
  const args = [
202
554
  "--disable-crash-handler",
203
555
  // No local HTTP API or ~/.summer discovery files for this throwaway run.
@@ -206,13 +558,18 @@ export async function captureGameplay(input, overrides = {}) {
206
558
  project,
207
559
  "--resolution",
208
560
  resolution.text,
561
+ "--max-fps",
562
+ String(CAPTURE_MAX_FPS),
209
563
  ...(scene ? ["--scene", scene] : []),
564
+ // The verify instance is offscreen already; the posture flag says so explicitly.
565
+ "--summer-offscreen",
210
566
  "--summer-verify",
211
567
  probe,
212
568
  "--summer-verify-out",
213
569
  out,
214
570
  "--summer-verify-max",
215
571
  String(maxSeconds),
572
+ ...(userArgs.length ? ["--", ...userArgs] : []),
216
573
  ];
217
574
  let run;
218
575
  try {
@@ -259,6 +616,9 @@ export async function captureGameplay(input, overrides = {}) {
259
616
  captured.push({ path, ...size });
260
617
  }
261
618
  const warnings = [...(results.frame_warnings ?? [])];
619
+ if (importCheck.lfsPointers.length) {
620
+ warnings.push(`These files are Git LFS pointers, not the real files, so the game shows them missing: ${importCheck.lfsPointers.join(", ")}. Run "git lfs pull" in the project, then capture again.`);
621
+ }
262
622
  if (results.finished === false)
263
623
  warnings.push("The game hit its time limit before every frame was saved.");
264
624
  const off = captured.filter((frame) => frame.width !== resolution.width || frame.height !== resolution.height);
@@ -276,6 +636,7 @@ export async function captureGameplay(input, overrides = {}) {
276
636
  out,
277
637
  engine: binary,
278
638
  imported,
639
+ ...(imported && importReason ? { importReason } : {}),
279
640
  durationMs: deps.now().getTime() - started.getTime(),
280
641
  ...(warnings.length ? { warnings } : {}),
281
642
  next: "Look at each frame before using it. For the store: upload with summer_upload_image_begin/complete and set them as desktop.screenshots or mobile.screenshots (summer_store_set_art). The game's own HUD may show; add no captions, logos or overlays.",
@@ -4,6 +4,13 @@
4
4
  * summer_export_game writes and fails after a full engine start.
5
5
  */
6
6
  export declare const EXPORT_MIN_ENGINE_VERSION = "0.7.0";
7
+ /** Bundle targets that need a newer engine than EXPORT_MIN_ENGINE_VERSION. */
8
+ export declare const TARGET_MIN_ENGINE_VERSION: Readonly<Record<string, string>>;
9
+ /** The requested targets this engine version cannot export; an unreadable version skips none. */
10
+ export declare function targetsNeedingNewerEngine(targets: readonly string[], version: string | null): Array<{
11
+ target: string;
12
+ needs: string;
13
+ }>;
7
14
  export declare const ENGINE_UPDATE_HOW = "run \"summer install --yes\" (or download it from summerengine.com/download)";
8
15
  /** Installed Summer version of an engine binary, or null when this system cannot read it. */
9
16
  export declare function installedEngineVersion(binary: string): string | null;
@@ -7,6 +7,15 @@ import { BuildToolError } from "./summer-bundle.js";
7
7
  * summer_export_game writes and fails after a full engine start.
8
8
  */
9
9
  export const EXPORT_MIN_ENGINE_VERSION = "0.7.0";
10
+ /** Bundle targets that need a newer engine than EXPORT_MIN_ENGINE_VERSION. */
11
+ export const TARGET_MIN_ENGINE_VERSION = { android: "0.7.1" };
12
+ /** The requested targets this engine version cannot export; an unreadable version skips none. */
13
+ export function targetsNeedingNewerEngine(targets, version) {
14
+ return targets.flatMap((target) => {
15
+ const needs = TARGET_MIN_ENGINE_VERSION[target];
16
+ return needs && isVersionAtLeast(version, needs) === false ? [{ target, needs }] : [];
17
+ });
18
+ }
10
19
  export const ENGINE_UPDATE_HOW = 'run "summer install --yes" (or download it from summerengine.com/download)';
11
20
  /** Installed Summer version of an engine binary, or null when this system cannot read it. */
12
21
  export function installedEngineVersion(binary) {
@@ -34,6 +34,8 @@ export interface ExportGameInput {
34
34
  out?: string;
35
35
  /** Export with debug enabled (--export-debug). */
36
36
  debug?: boolean;
37
+ /** Bundle with targets: set summer.build.json targetPlatforms to the targets first, so the store takes the declaration. */
38
+ alignDeclaration?: boolean;
37
39
  /** Export preset name; the engine's own is "summer.games". Bundle format without targets only. */
38
40
  preset?: string;
39
41
  /** Store platforms: macos, windows, ios, android, web. */
@@ -52,6 +54,8 @@ export interface ExportGameResult {
52
54
  preset: string;
53
55
  /** The targets asked for, when any were. */
54
56
  targets?: StoreTarget[];
57
+ /** Asked-for targets left out of the bundle (an engine too old for them); warnings say why. */
58
+ skippedTargets?: StoreTarget[];
55
59
  /** What happened to export_presets.cfg for the preset this export used. */
56
60
  presetChange?: EnsuredPreset["change"];
57
61
  debug: boolean;
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { platform } from "node:os";
5
5
  import { engineSelectionFromEnv } from "../engine.js";
@@ -7,10 +7,10 @@ import { findEngineBinary } from "../engine-install.js";
7
7
  import { readStoreJson, writeStoreJson } from "../store.js";
8
8
  import { writeZip } from "../util/zip-write.js";
9
9
  import { engineErrorLines, runEngine } from "./engine-run.js";
10
- import { assertExportEngine, installedEngineVersion } from "./export-engine-version.js";
10
+ import { assertExportEngine, ENGINE_UPDATE_HOW, installedEngineVersion, targetsNeedingNewerEngine } from "./export-engine-version.js";
11
11
  import { BUNDLE_TARGETS, DOWNLOAD_TARGETS, SUMMER_GAMES_PRESET, bundlePresetSpec, ensurePreset, normalizeTargets, variantString, } from "./export-presets.js";
12
12
  import { engineTemplateInfo, findInstalledTemplate, templateFileFor } from "./export-templates.js";
13
- import { BuildToolError, hashFile, readSummerBundle } from "./summer-bundle.js";
13
+ import { BuildToolError, declarationMismatch, hashFile, readSummerBundle, samePlatforms } from "./summer-bundle.js";
14
14
  export { runEngine, SUMMER_GAMES_PRESET };
15
15
  /**
16
16
  * summer_export_game: run the installed Summer Engine headless and return the
@@ -272,10 +272,27 @@ async function runExport(deps, binary, project, preset, out, debug, timeoutMs, n
272
272
  throw new BuildToolError("export_failed", `Summer Engine did not export the game (exit ${run.code ?? run.signal})${named.length ? `: ${named.join(" | ")}` : "."}`, [exportFailureRecovery(errors, preset), ...notes].join(" "), undefined, { errors, output: outputTail(run.output) });
273
273
  }
274
274
  }
275
+ /** Set the project's summer.build.json targetPlatforms to the export targets (a hosted game's Build declaration). */
276
+ async function alignBuildDeclaration(project, targets) {
277
+ const path = join(project, "summer.build.json");
278
+ if (!existsSync(path))
279
+ return;
280
+ let build;
281
+ try {
282
+ build = JSON.parse(await readFile(path, "utf8"));
283
+ }
284
+ catch {
285
+ throw new BuildToolError("build_declaration_invalid", "summer.build.json is not valid JSON.", "Recovery: fix summer.build.json, then export again.");
286
+ }
287
+ if (Array.isArray(build.targetPlatforms) && samePlatforms(build.targetPlatforms.map(String), targets))
288
+ return;
289
+ build.targetPlatforms = [...targets];
290
+ await writeFile(path, `${JSON.stringify(build, null, 2)}\n`);
291
+ }
275
292
  export async function exportGame(input, overrides = {}) {
276
293
  const deps = { ...defaultDependencies, ...overrides };
277
294
  const project = resolveExportProject(input.project);
278
- const targets = input.targets?.length ? normalizeTargets(input.targets) : undefined;
295
+ let targets = input.targets?.length ? normalizeTargets(input.targets) : undefined;
279
296
  const format = resolveFormat(input, targets);
280
297
  const debug = input.debug === true;
281
298
  const started = deps.now();
@@ -293,13 +310,26 @@ export async function exportGame(input, overrides = {}) {
293
310
  throw new BuildToolError("engine_not_installed", "Summer Engine is not installed on this machine.", 'Recovery: run "summer install", or set SUMMER_BIN to the engine executable, then export again.');
294
311
  }
295
312
  // Before any preset is written: an old engine cannot read them.
296
- assertExportEngine(binary, deps.engineVersion(binary));
313
+ const engineVersion = deps.engineVersion(binary);
314
+ assertExportEngine(binary, engineVersion);
315
+ // A target this engine cannot build is left out, not the whole export.
316
+ const skipped = format === "bundle" && targets ? targetsNeedingNewerEngine(targets, engineVersion) : [];
317
+ const skippedWarnings = skipped.map(({ target, needs }) => `${target} was not exported: it needs Summer Engine ${needs} or newer, and ${engineVersion} is installed. Update (${ENGINE_UPDATE_HOW}) and export ${target} again.`);
318
+ if (skipped.length) {
319
+ const left = targets.filter((target) => !skipped.some((entry) => entry.target === target));
320
+ if (!left.length) {
321
+ throw new BuildToolError("export_target_unsupported", `Summer Engine ${engineVersion} cannot export ${skipped.map((entry) => entry.target).join(", ")}.`, `Recovery: ${skippedWarnings.join(" ")}`, undefined, { engineVersion, skippedTargets: skipped });
322
+ }
323
+ targets = left;
324
+ }
297
325
  if (format === "download") {
298
326
  return exportDownload(deps, binary, project, targets[0], out, debug, timeoutMs, started);
299
327
  }
300
328
  out ??= join(await defaultExportDir(project), `${basename(project)}-${stamp(started)}.zip`);
301
329
  const before = existsSync(out) ? statSync(out).mtimeMs : null;
302
330
  const projectBefore = await snapshotProjectFiles(project);
331
+ if (targets && input.alignDeclaration === true)
332
+ await alignBuildDeclaration(project, targets);
303
333
  const ensured = targets ? await ensurePreset(project, bundlePresetSpec(targets)) : undefined;
304
334
  const preset = ensured?.name ?? (input.preset?.trim() || SUMMER_GAMES_PRESET);
305
335
  const notes = (targets ?? []).flatMap((target) => TARGET_ENGINE_NOTE[target] ?? []);
@@ -310,10 +340,20 @@ export async function exportGame(input, overrides = {}) {
310
340
  }
311
341
  const bundle = await readSummerBundle(out);
312
342
  const missing = (targets ?? []).filter((target) => !bundle.targetPlatforms.includes(target));
313
- if (missing.length) {
343
+ // Some targets made it: keep the bundle and say which did not.
344
+ const partial = missing.length > 0 && missing.length < (targets ?? []).length;
345
+ if (missing.length && !partial) {
314
346
  throw new BuildToolError("export_target_unsupported", `This Summer Engine exported the bundle without ${missing.join(", ")}: its summer.games preset has no such platform.`, ["Recovery: update Summer Engine and export again, or export without those targets.", ...missing.flatMap((target) => TARGET_ENGINE_NOTE[target] ?? [])].join(" "), undefined, { exportedPlatforms: bundle.targetPlatforms, path: out });
315
347
  }
316
- const warnings = projectChangeWarnings(projectChanges);
348
+ const warnings = [...skippedWarnings, ...projectChangeWarnings(projectChanges)];
349
+ const mismatch = declarationMismatch(bundle);
350
+ if (mismatch) {
351
+ warnings.unshift(`summer_publish_build will refuse this export (declaration_mismatch): ${mismatch}. Export again with alignDeclaration:true to set summer.build.json targetPlatforms to [${bundle.targetPlatforms.join(", ")}], or with targets equal to what it declares.`);
352
+ }
353
+ if (partial) {
354
+ warnings.push(`This Summer Engine exported the bundle without ${missing.join(", ")}: its summer.games preset has no such platform. ` +
355
+ ["Update Summer Engine and export them again.", ...missing.flatMap((target) => TARGET_ENGINE_NOTE[target] ?? [])].join(" "));
356
+ }
317
357
  const desktopOnly = bundle.hosted ? [] : bundle.targetPlatforms.filter((target) => !STANDALONE_TARGETS.includes(target));
318
358
  if (desktopOnly.length) {
319
359
  warnings.push(`This game has no server, and the store runs games without a server on iPhone and Android only: summer_publish_build will refuse ${desktopOnly.join(", ")}. ` +
@@ -332,6 +372,9 @@ export async function exportGame(input, overrides = {}) {
332
372
  project,
333
373
  preset,
334
374
  ...(targets ? { targets, presetChange: ensured.change } : {}),
375
+ ...(skipped.length || partial
376
+ ? { skippedTargets: [...skipped.map((entry) => entry.target), ...(partial ? missing : [])] }
377
+ : {}),
335
378
  debug,
336
379
  engine: binary,
337
380
  durationMs: deps.now().getTime() - started.getTime(),