summer-engine 3.3.0 → 3.4.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 (68) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +4 -2
  3. package/.codex-plugin/plugin.json +5 -3
  4. package/.cursor-plugin/plugin.json +4 -2
  5. package/.factory-plugin/plugin.json +4 -2
  6. package/CHANGELOG.md +36 -0
  7. package/README.md +2 -1
  8. package/assets/api-docs.json.gz +0 -0
  9. package/dist/cli/commands/install.d.ts +10 -0
  10. package/dist/cli/commands/install.js +55 -10
  11. package/dist/cli/commands/tool.d.ts +7 -0
  12. package/dist/cli/commands/tool.js +36 -1
  13. package/dist/core/capabilities/capture-gameplay.d.ts +69 -0
  14. package/dist/core/capabilities/capture-gameplay.js +283 -0
  15. package/dist/core/capabilities/creator.js +1 -1
  16. package/dist/core/capabilities/doctor.d.ts +9 -1
  17. package/dist/core/capabilities/doctor.js +47 -4
  18. package/dist/core/capabilities/engine-run.d.ts +4 -0
  19. package/dist/core/capabilities/engine-run.js +33 -5
  20. package/dist/core/capabilities/export-engine-version.d.ts +15 -0
  21. package/dist/core/capabilities/export-engine-version.js +24 -0
  22. package/dist/core/capabilities/export-game.d.ts +9 -1
  23. package/dist/core/capabilities/export-game.js +112 -36
  24. package/dist/core/capabilities/image-generation.d.ts +9 -0
  25. package/dist/core/capabilities/image-generation.js +19 -1
  26. package/dist/core/capabilities/publish-build.d.ts +14 -1
  27. package/dist/core/capabilities/publish-build.js +74 -22
  28. package/dist/core/capabilities/tool-dispatch.js +10 -0
  29. package/dist/core/config.js +2 -2
  30. package/dist/core/engine-install.d.ts +16 -0
  31. package/dist/core/engine-install.js +32 -2
  32. package/dist/core/hosted-mcp.d.ts +115 -0
  33. package/dist/core/hosted-mcp.js +164 -0
  34. package/dist/core/launch-posture.d.ts +3 -0
  35. package/dist/core/launch-posture.js +15 -2
  36. package/dist/mcp/hosted-mount.d.ts +13 -59
  37. package/dist/mcp/hosted-mount.js +24 -30
  38. package/dist/mcp/server.js +3 -2
  39. package/dist/mcp/tools/creator-tools.js +13 -3
  40. package/dist/mcp/tools/generate-tools.js +12 -2
  41. package/gemini-extension.json +3 -1
  42. package/library/references/mcp-tools-reference/mcp-tools-reference.md +3 -2
  43. package/library/skills/README.md +6 -4
  44. package/library/skills/bounded-engine-runs/SKILL.md +96 -0
  45. package/library/skills/bounded-engine-runs/resource.yaml +29 -0
  46. package/library/skills/compatibility-renderer-traps/SKILL.md +78 -0
  47. package/library/skills/compatibility-renderer-traps/resource.yaml +28 -0
  48. package/library/skills/concept-art/SKILL.md +2 -2
  49. package/library/skills/export-and-ship/SKILL.md +2 -0
  50. package/library/skills/headless-scripting/resource.yaml +1 -0
  51. package/library/skills/in-game-purchases/SKILL.md +123 -118
  52. package/library/skills/in-game-purchases/resource.yaml +2 -2
  53. package/library/skills/web-compatibility-look/resource.yaml +1 -0
  54. package/library/tools/capture-gameplay/resource.yaml +73 -0
  55. package/library/tools/generate-image/resource.yaml +15 -1
  56. package/library/tools/publish-build/resource.yaml +1 -1
  57. package/package.json +1 -1
  58. package/registry/generated/aliases.json +1 -0
  59. package/registry/generated/counts.json +3 -3
  60. package/registry/generated/gemini-extension.json +3 -1
  61. package/registry/generated/index.json +125 -12
  62. package/registry/generated/marketplace.claude.json +2 -2
  63. package/registry/generated/plugin.claude.json +4 -2
  64. package/registry/generated/plugin.codex.json +5 -3
  65. package/registry/generated/plugin.cursor.json +4 -2
  66. package/registry/generated/plugin.factory.json +4 -2
  67. package/registry/generated/skills-index.md +6 -4
  68. package/registry/generated/skills-registry.json +28 -1
@@ -6,7 +6,8 @@ import { engineSelectionFromEnv } from "../engine.js";
6
6
  import { findEngineBinary } from "../engine-install.js";
7
7
  import { readStoreJson, writeStoreJson } from "../store.js";
8
8
  import { writeZip } from "../util/zip-write.js";
9
- import { runEngine } from "./engine-run.js";
9
+ import { engineErrorLines, runEngine } from "./engine-run.js";
10
+ import { assertExportEngine, installedEngineVersion } from "./export-engine-version.js";
10
11
  import { BUNDLE_TARGETS, DOWNLOAD_TARGETS, SUMMER_GAMES_PRESET, bundlePresetSpec, ensurePreset, normalizeTargets, variantString, } from "./export-presets.js";
11
12
  import { engineTemplateInfo, findInstalledTemplate, templateFileFor } from "./export-templates.js";
12
13
  import { BuildToolError, hashFile, readSummerBundle } from "./summer-bundle.js";
@@ -15,7 +16,7 @@ export { runEngine, SUMMER_GAMES_PRESET };
15
16
  * summer_export_game: run the installed Summer Engine headless and return the
16
17
  * file the Summer Games store takes for the chosen targets.
17
18
  *
18
- * <engine> --headless --path <project> --export-release "<preset>" <out>
19
+ * <engine> --headless --summer-no-api --path <project> --export-release "<preset>" <out>
19
20
  *
20
21
  * format "bundle" (default): the summer.bundle.v1 .zip for build-publications
21
22
  * (summer_publish_build). Summer runs its client.pck on its own templates in
@@ -51,6 +52,7 @@ const TARGET_ENGINE_NOTE = {
51
52
  };
52
53
  const defaultDependencies = {
53
54
  findBinary: () => findEngineBinary(platform()),
55
+ engineVersion: installedEngineVersion,
54
56
  run: runEngine,
55
57
  now: () => new Date(),
56
58
  };
@@ -81,6 +83,69 @@ async function defaultExportDir(project) {
81
83
  function outputTail(output) {
82
84
  return output.trim().split("\n").slice(-40).join("\n");
83
85
  }
86
+ /** size and mtime of every file at the top of the project folder (project.godot, export_presets.cfg, ...). */
87
+ async function snapshotProjectFiles(project) {
88
+ const files = new Map();
89
+ for (const entry of await readdir(project, { withFileTypes: true })) {
90
+ if (!entry.isFile())
91
+ continue;
92
+ const info = await stat(join(project, entry.name)).catch(() => null);
93
+ if (info)
94
+ files.set(entry.name, `${info.size}:${info.mtimeMs}`);
95
+ }
96
+ return files;
97
+ }
98
+ async function projectChangesSince(project, before, output) {
99
+ const after = await snapshotProjectFiles(project);
100
+ const changes = [];
101
+ for (const [file, value] of after) {
102
+ if (output && resolve(project, file) === resolve(output))
103
+ continue;
104
+ if (!before.has(file))
105
+ changes.push({ file, change: "created" });
106
+ else if (before.get(file) !== value)
107
+ changes.push({ file, change: "modified" });
108
+ }
109
+ for (const file of before.keys())
110
+ if (!after.has(file))
111
+ changes.push({ file, change: "deleted" });
112
+ return changes.sort((a, b) => a.file.localeCompare(b.file));
113
+ }
114
+ function projectChangeWarnings(changes) {
115
+ return changes.some((entry) => entry.file === "project.godot" && entry.change === "modified")
116
+ ? ["Summer Engine rewrote project.godot while it exported (an engine update cleans up old settings; the previous file is project.godot.bak). Review the change and commit or revert it."]
117
+ : [];
118
+ }
119
+ /** Re-throw an export failure with the project files it already changed. */
120
+ async function withProjectChanges(project, before, work) {
121
+ try {
122
+ return await work();
123
+ }
124
+ catch (error) {
125
+ if (!(error instanceof BuildToolError))
126
+ throw error;
127
+ const changes = await projectChangesSince(project, before).catch(() => []);
128
+ if (!changes.length)
129
+ throw error;
130
+ throw new BuildToolError(error.code, error.message.replace(` ${error.recovery}`, ""), error.recovery, error.status, { ...error.detail, projectChanges: changes });
131
+ }
132
+ }
133
+ /** Plain recovery for what the engine printed; the generic preset hint only when a preset is what it names. */
134
+ function exportFailureRecovery(errors, preset) {
135
+ const text = errors.join("\n");
136
+ if (/export preset/i.test(text) && /(invalid|not found|no such|unknown)/i.test(text)) {
137
+ return `Recovery: this engine has no "${preset}" export preset, which means it predates summer.games exports: update Summer Engine to 0.7.0+ (run "summer install --yes"), then export again.`;
138
+ }
139
+ if (/source graph|client pack|authority|domain/i.test(text)) {
140
+ return ("Recovery: this is a hosted game whose source graph puts a resource the client loads in a server-only (authority) domain. " +
141
+ "Open the source graph summer.build.json names (source_graph, usually source-domains.json) and narrow the authority root to the server-only files " +
142
+ "(for example the authority scene and its script), so the scenes and resources the client starts (such as the network composition .tres) sit under a shared or client root. " +
143
+ "The multiplayer-project skill explains the domains. Then export again.");
144
+ }
145
+ return errors.length
146
+ ? "Recovery: fix what the engine names above and export again."
147
+ : "Recovery: the engine printed no ERROR line; read output, fix what it names, and export again.";
148
+ }
84
149
  /** One value from project.godot ([section] key=value), unquoted. */
85
150
  export function projectSetting(project, section, key) {
86
151
  let current = "";
@@ -189,7 +254,8 @@ async function listFiles(root) {
189
254
  return files.sort();
190
255
  }
191
256
  async function runExport(deps, binary, project, preset, out, debug, timeoutMs, notes) {
192
- const args = ["--headless", "--path", project, debug ? "--export-debug" : "--export-release", preset, out];
257
+ // --summer-no-api: an export needs no local HTTP API; nothing binds 127.0.0.1:6550 or writes discovery files.
258
+ const args = ["--headless", "--summer-no-api", "--path", project, debug ? "--export-debug" : "--export-release", preset, out];
193
259
  let run;
194
260
  try {
195
261
  run = await deps.run(binary, args, timeoutMs);
@@ -201,10 +267,9 @@ async function runExport(deps, binary, project, preset, out, debug, timeoutMs, n
201
267
  throw new BuildToolError("export_timeout", `The export did not finish within ${Math.round(timeoutMs / 1000)} seconds and was stopped.`, "Recovery: a first export imports every asset; retry with a larger timeoutSeconds.", undefined, { output: outputTail(run.output) });
202
268
  }
203
269
  if (run.code !== 0 || !existsSync(out)) {
204
- throw new BuildToolError("export_failed", `Summer Engine did not export the game (exit ${run.code ?? run.signal}).`, [
205
- `Recovery: read the engine output, fix what it names, and export again. A missing "${preset}" preset means this engine predates summer.games exports: update Summer Engine.`,
206
- ...notes,
207
- ].join(" "), undefined, { output: outputTail(run.output) });
270
+ const errors = run.errors?.length ? run.errors : engineErrorLines(run.output);
271
+ const named = errors.slice(0, 3).map((line) => line.replace(/^(SCRIPT )?ERROR:\s*/, ""));
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) });
208
273
  }
209
274
  }
210
275
  export async function exportGame(input, overrides = {}) {
@@ -227,15 +292,19 @@ export async function exportGame(input, overrides = {}) {
227
292
  if (!binary) {
228
293
  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.');
229
294
  }
295
+ // Before any preset is written: an old engine cannot read them.
296
+ assertExportEngine(binary, deps.engineVersion(binary));
230
297
  if (format === "download") {
231
298
  return exportDownload(deps, binary, project, targets[0], out, debug, timeoutMs, started);
232
299
  }
233
300
  out ??= join(await defaultExportDir(project), `${basename(project)}-${stamp(started)}.zip`);
234
301
  const before = existsSync(out) ? statSync(out).mtimeMs : null;
302
+ const projectBefore = await snapshotProjectFiles(project);
235
303
  const ensured = targets ? await ensurePreset(project, bundlePresetSpec(targets)) : undefined;
236
304
  const preset = ensured?.name ?? (input.preset?.trim() || SUMMER_GAMES_PRESET);
237
305
  const notes = (targets ?? []).flatMap((target) => TARGET_ENGINE_NOTE[target] ?? []);
238
- await runExport(deps, binary, project, preset, out, debug, timeoutMs, notes);
306
+ await withProjectChanges(project, projectBefore, () => runExport(deps, binary, project, preset, out, debug, timeoutMs, notes));
307
+ const projectChanges = await projectChangesSince(project, projectBefore, out);
239
308
  if (before !== null && statSync(out).mtimeMs === before) {
240
309
  throw new BuildToolError("export_failed", "Summer Engine exited without writing a new bundle.", "Recovery: read the engine output and export again.");
241
310
  }
@@ -244,7 +313,7 @@ export async function exportGame(input, overrides = {}) {
244
313
  if (missing.length) {
245
314
  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 });
246
315
  }
247
- const warnings = [];
316
+ const warnings = projectChangeWarnings(projectChanges);
248
317
  const desktopOnly = bundle.hosted ? [] : bundle.targetPlatforms.filter((target) => !STANDALONE_TARGETS.includes(target));
249
318
  if (desktopOnly.length) {
250
319
  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(", ")}. ` +
@@ -267,6 +336,7 @@ export async function exportGame(input, overrides = {}) {
267
336
  engine: binary,
268
337
  durationMs: deps.now().getTime() - started.getTime(),
269
338
  bundle: { ...summary, fileCount: files.length },
339
+ ...(projectChanges.length ? { projectChanges } : {}),
270
340
  ...(warnings.length ? { warnings } : {}),
271
341
  next: "Upload it to the game's store listing with summer_publish_build (gameId, clientVersion).",
272
342
  };
@@ -296,6 +366,7 @@ async function exportDownload(deps, binary, project, target, requestedOut, debug
296
366
  throw error;
297
367
  }
298
368
  const spec = downloadPresetSpec(target, slug);
369
+ const projectBefore = await snapshotProjectFiles(project);
299
370
  const ensured = await ensurePreset(project, spec);
300
371
  const exportsDir = await defaultExportDir(project);
301
372
  const work = join(exportsDir, `${slug}-${target}-${stamp(started)}`);
@@ -304,37 +375,41 @@ async function exportDownload(deps, binary, project, target, requestedOut, debug
304
375
  const out = requestedOut ?? join(exportsDir, `${slug}-${storePlatform}-${stamp(started)}.zip`);
305
376
  const warnings = [];
306
377
  let fileCount = 1;
307
- try {
308
- if (target === "macos") {
309
- // The macOS exporter writes the .app inside a .zip itself.
310
- const engineOut = join(work, `${slug}.zip`);
311
- await runExport(deps, binary, project, spec.name, engineOut, debug, timeoutMs, []);
312
- await rename(engineOut, out);
313
- warnings.push("macOS shows players an unidentified-developer prompt for an ad hoc signed app; the store accepts it as is.");
314
- }
315
- else {
316
- const engineOut = join(work, target === "web" ? "index.html" : `${slug}.exe`);
317
- await runExport(deps, binary, project, spec.name, engineOut, debug, timeoutMs, []);
318
- const files = await listFiles(work);
319
- const entries = files.map((file) => ({ name: relative(work, file).split(sep).join("/"), source: file }));
320
- if (target === "web") {
321
- const sizes = await Promise.all(files.map(async (file) => (await stat(file)).size));
322
- const total = sizes.reduce((sum, size) => sum + size, 0);
323
- const tooBig = entries.find((_entry, index) => sizes[index] > WEB_LIMITS.fileBytes);
324
- if (entries.length > WEB_LIMITS.files || total > WEB_LIMITS.zipBytes || tooBig) {
325
- throw new BuildToolError("web_build_too_large", `The web build has ${entries.length} files and ${Math.round(total / 1048576)} MiB${tooBig ? `; ${tooBig.name} is over 200 MiB` : ""}. The store takes at most ${WEB_LIMITS.files} files, 500 MiB, 200 MiB per file.`, "Recovery: exclude unused assets from the export (Project > Export > Resources) or compress textures, then export again.");
326
- }
378
+ await withProjectChanges(project, projectBefore, async () => {
379
+ try {
380
+ if (target === "macos") {
381
+ // The macOS exporter writes the .app inside a .zip itself.
382
+ const engineOut = join(work, `${slug}.zip`);
383
+ await runExport(deps, binary, project, spec.name, engineOut, debug, timeoutMs, []);
384
+ await rename(engineOut, out);
385
+ warnings.push("macOS shows players an unidentified-developer prompt for an ad hoc signed app; the store accepts it as is.");
327
386
  }
328
387
  else {
329
- warnings.push("The .exe is not code signed; Windows SmartScreen may warn players. The store accepts it as is.");
388
+ const engineOut = join(work, target === "web" ? "index.html" : `${slug}.exe`);
389
+ await runExport(deps, binary, project, spec.name, engineOut, debug, timeoutMs, []);
390
+ const files = await listFiles(work);
391
+ const entries = files.map((file) => ({ name: relative(work, file).split(sep).join("/"), source: file }));
392
+ if (target === "web") {
393
+ const sizes = await Promise.all(files.map(async (file) => (await stat(file)).size));
394
+ const total = sizes.reduce((sum, size) => sum + size, 0);
395
+ const tooBig = entries.find((_entry, index) => sizes[index] > WEB_LIMITS.fileBytes);
396
+ if (entries.length > WEB_LIMITS.files || total > WEB_LIMITS.zipBytes || tooBig) {
397
+ throw new BuildToolError("web_build_too_large", `The web build has ${entries.length} files and ${Math.round(total / 1048576)} MiB${tooBig ? `; ${tooBig.name} is over 200 MiB` : ""}. The store takes at most ${WEB_LIMITS.files} files, 500 MiB, 200 MiB per file.`, "Recovery: exclude unused assets from the export (Project > Export > Resources) or compress textures, then export again.");
398
+ }
399
+ }
400
+ else {
401
+ warnings.push("The .exe is not code signed; Windows SmartScreen may warn players. The store accepts it as is.");
402
+ }
403
+ await writeZip(out, entries);
404
+ fileCount = entries.length;
330
405
  }
331
- await writeZip(out, entries);
332
- fileCount = entries.length;
333
406
  }
334
- }
335
- finally {
336
- await rm(work, { recursive: true, force: true });
337
- }
407
+ finally {
408
+ await rm(work, { recursive: true, force: true });
409
+ }
410
+ });
411
+ const projectChanges = await projectChangesSince(project, projectBefore, out);
412
+ warnings.unshift(...projectChangeWarnings(projectChanges));
338
413
  const icon = projectSetting(project, "application", "config/icon");
339
414
  if (!icon)
340
415
  warnings.push("The project has no icon (Project Settings > Application > Config > Icon); the export uses the Summer default.");
@@ -358,6 +433,7 @@ async function exportDownload(deps, binary, project, target, requestedOut, debug
358
433
  fileCount,
359
434
  icon,
360
435
  signing: target === "macos" ? "ad hoc (built-in), not notarized" : "none",
436
+ ...(projectChanges.length ? { projectChanges } : {}),
361
437
  ...(warnings.length ? { warnings } : {}),
362
438
  next: `Upload it as the game's ${storePlatform} store version with summer_publish_build (gameId, clientVersion); it reads the platform from this export.`,
363
439
  };
@@ -6,19 +6,28 @@ export declare const imageGenerationArgsSchema: z.ZodObject<{
6
6
  style: z.ZodDefault<z.ZodString>;
7
7
  referenceImageUrl: z.ZodOptional<z.ZodString>;
8
8
  removeBackground: z.ZodOptional<z.ZodBoolean>;
9
+ aspectRatio: z.ZodOptional<z.ZodEnum<["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "5:4", "4:5", "21:9"]>>;
10
+ width: z.ZodOptional<z.ZodNumber>;
11
+ height: z.ZodOptional<z.ZodNumber>;
9
12
  options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
10
13
  }, "strip", z.ZodTypeAny, {
11
14
  prompt: string;
12
15
  model: string;
13
16
  style: string;
17
+ width?: number | undefined;
18
+ height?: number | undefined;
14
19
  options?: Record<string, any> | undefined;
15
20
  referenceImageUrl?: string | undefined;
16
21
  removeBackground?: boolean | undefined;
22
+ aspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4" | "3:2" | "2:3" | "5:4" | "4:5" | "21:9" | undefined;
17
23
  }, {
18
24
  prompt: string;
25
+ width?: number | undefined;
26
+ height?: number | undefined;
19
27
  options?: Record<string, any> | undefined;
20
28
  model?: string | undefined;
21
29
  style?: string | undefined;
22
30
  referenceImageUrl?: string | undefined;
23
31
  removeBackground?: boolean | undefined;
32
+ aspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4" | "3:2" | "2:3" | "5:4" | "4:5" | "21:9" | undefined;
24
33
  }>;
@@ -15,8 +15,26 @@ export const imageGenerationArgsSchema = z.object({
15
15
  .optional()
16
16
  .describe("Source image URL for img2img / edit mode. The prompt describes how to transform this image."),
17
17
  removeBackground: z.boolean().optional().describe("Remove the background server-side and return an alpha PNG for sprites, icons, or isolated objects."),
18
+ aspectRatio: z
19
+ .enum(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "5:4", "4:5", "21:9"])
20
+ .optional()
21
+ .describe("Aspect ratio of the output (default 1:1). The result reports the real width and height."),
22
+ width: z
23
+ .number()
24
+ .int()
25
+ .min(64)
26
+ .max(4096)
27
+ .optional()
28
+ .describe("Smallest output width in pixels; pass with height. 1920 with height 1080 for store key art, 1080 with 1920 for a tall cover."),
29
+ height: z
30
+ .number()
31
+ .int()
32
+ .min(64)
33
+ .max(4096)
34
+ .optional()
35
+ .describe("Smallest output height in pixels; pass with width."),
18
36
  options: z
19
37
  .record(z.any())
20
38
  .optional()
21
- .describe("Provider-specific params (guidance_scale, seed, image_size, negative_prompt, etc.)"),
39
+ .describe("Extra model params (seed, negative_prompt, ...). The result lists any it did not use in ignoredOptions."),
22
40
  });
@@ -32,7 +32,7 @@ export interface Store {
32
32
  key?: string;
33
33
  }): Promise<T>;
34
34
  }
35
- export declare function storeError(status: number, code: string, message: string | undefined): BuildToolError;
35
+ export declare function storeError(status: number, code: string, message: string | undefined, requestId?: string): BuildToolError;
36
36
  export declare function createStore(baseUrl: string, token: string, deps: PublishBuildDependencies): Store;
37
37
  /** Whole-file and per-part SHA-256 ("sha256:<hex>") in one pass. */
38
38
  export declare function hashParts(path: string, partSize: number): Promise<{
@@ -57,6 +57,19 @@ export declare function uploadParts(sign: (partNumbers: number[]) => Promise<Gra
57
57
  partCount: number;
58
58
  }, deps: PublishBuildDependencies, parts?: number[]): Promise<void>;
59
59
  export declare function normalizeClientVersion(value?: string): string;
60
+ /**
61
+ * Before the person confirms, prove the target is real: the game exists for
62
+ * this account, and its store page lists the platforms the export targets.
63
+ */
64
+ export declare function checkStoreGame(gameId: string, exportTargets: string[], deps: PublishBuildDependencies): Promise<{
65
+ game: {
66
+ gameId: string;
67
+ name: string | null;
68
+ status: string | null;
69
+ targets: string[];
70
+ };
71
+ warnings: string[];
72
+ }>;
60
73
  export type PublishBuildResult = Record<string, unknown> & {
61
74
  ok: true;
62
75
  status: string;
@@ -61,9 +61,11 @@ export const defaultDependencies = {
61
61
  now: Date.now,
62
62
  token: () => getStoreAccessToken(),
63
63
  };
64
- export function storeError(status, code, message) {
64
+ export function storeError(status, code, message, requestId) {
65
65
  if (status === 401) {
66
- return new BuildToolError("store_auth_refused", "The Summer store refused this sign-in.", 'Recovery: run "summer login --store --force" and retry. If it is refused again, the store does not accept agent sign-ins yet: upload the .zip in Studio (Store > your game > Exports) until it does.', status);
66
+ // The token was sent and refused: signing in again gives the same token
67
+ // shape and the same answer, so it is not the first thing to try.
68
+ return new BuildToolError("store_auth_refused", `The Summer store did not accept this sign-in${message ? `: ${message.replace(/\.$/, "")}` : ""}.`, 'Recovery: run "summer doctor" (Store line) to see the store\'s answer. Signing in again does not change a refusal of a valid sign-in. Until the store accepts agent sign-in for this account, upload the .zip in Studio (Store > your game > Exports).', status, requestId ? { requestId } : undefined);
67
69
  }
68
70
  if (status === 403 && code === "owner_approval_required") {
69
71
  return new BuildToolError("owner_approval_required", message || "Only the game's owner can do this.", "Recovery: nothing went live. Submit with summer_store_submit and give the owner the approval link it returns; a live game's new download is finished by the owner in Studio.", status);
@@ -104,7 +106,8 @@ export function createStore(baseUrl, token, deps) {
104
106
  const body = (json && typeof json === "object" ? json : {});
105
107
  if (!response.ok) {
106
108
  const error = body.error && typeof body.error === "object" ? body.error : {};
107
- throw storeError(response.status, typeof error.code === "string" ? error.code : `http_${response.status}`, typeof error.message === "string" ? error.message : undefined);
109
+ const requestId = [error.requestId, body.requestId, response.headers.get("x-request-id")].find((value) => typeof value === "string" && value);
110
+ throw storeError(response.status, typeof error.code === "string" ? error.code : `http_${response.status}`, typeof error.message === "string" ? error.message : undefined, requestId);
108
111
  }
109
112
  return body;
110
113
  },
@@ -260,18 +263,68 @@ function targetPlatforms(bundle) {
260
263
  // The Build declares exactly what the bundle declares (declaration_mismatch otherwise).
261
264
  return bundle.targetPlatforms;
262
265
  }
263
- /** gameId is required: the error carries the person's games when the store answers. */
264
- async function listGamesOrThrow(deps) {
265
- let games;
266
+ /** The signed-in person's store games, or why the store did not list them. */
267
+ async function listGames(deps) {
266
268
  try {
267
- const store = createStore(await resolveGatewayUrl(), await deps.token(), deps);
269
+ const store = createStore(await resolveGatewayUrl(), await storeToken(deps), deps);
268
270
  const page = await store.call("GET", "games");
269
- games = (page.items ?? []).map((game) => ({ id: game.id, name: game.name, slug: game.slug, status: game.status }));
271
+ return { games: (page.items ?? []).map((game) => ({ id: game.id, name: game.name, slug: game.slug, status: game.status, targets: game.supportedPlatforms ?? [] })) };
270
272
  }
271
- catch {
272
- games = undefined;
273
+ catch (error) {
274
+ return { error: error instanceof Error ? error.message : String(error) };
275
+ }
276
+ }
277
+ /** gameId is required: the error carries the person's games, or why the store did not list them. */
278
+ async function listGamesOrThrow(deps) {
279
+ const listed = await listGames(deps);
280
+ if ("games" in listed) {
281
+ throw new BuildToolError("game_required", listed.games.length
282
+ ? "gameId is required: pick one of your store games below (the export is uploaded to one existing game)."
283
+ : "gameId is required, and this account has no store game yet.", listed.games.length
284
+ ? "Recovery: call again with gameId from the list."
285
+ : "Recovery: create the store game first (summer_store_create_game), then call again with its gameId.", undefined, { games: listed.games });
286
+ }
287
+ throw new BuildToolError("game_required", `gameId is required, and the store did not list your games: ${listed.error}`, "Recovery: copy the game id from Studio (the store page URL) and retry.", undefined, { listError: listed.error });
288
+ }
289
+ async function storeToken(deps) {
290
+ try {
291
+ return await deps.token();
292
+ }
293
+ catch (error) {
294
+ if (error instanceof OAuthError)
295
+ throw new BuildToolError(error.code, error.message.replace(` ${error.recovery}`, ""), error.recovery);
296
+ throw error;
297
+ }
298
+ }
299
+ /**
300
+ * Before the person confirms, prove the target is real: the game exists for
301
+ * this account, and its store page lists the platforms the export targets.
302
+ */
303
+ export async function checkStoreGame(gameId, exportTargets, deps) {
304
+ const store = createStore(await resolveGatewayUrl(), await storeToken(deps), deps);
305
+ let game;
306
+ try {
307
+ game = await store.call("GET", `games/${seg(gameId)}`);
308
+ }
309
+ catch (error) {
310
+ if (error instanceof BuildToolError && error.status === 404) {
311
+ const listed = await listGames(deps);
312
+ throw new BuildToolError("game_not_found", "games" in listed
313
+ ? `There is no store game "${gameId}" for this account, so nothing can be uploaded to it.`
314
+ : `The store did not find game "${gameId}", and did not list your games either: ${listed.error}`, "games" in listed && listed.games.length
315
+ ? "Recovery: call again with gameId from the list."
316
+ : "Recovery: create the store game first (summer_store_create_game), or copy its id from Studio (the store page URL).", 404, "games" in listed ? { games: listed.games } : undefined);
317
+ }
318
+ throw error;
273
319
  }
274
- throw new BuildToolError("game_required", "gameId is required: the export is uploaded to one existing game.", games ? "Recovery: pick the game's id from the list and retry." : "Recovery: copy the game id from Studio (the store page URL) and retry.", undefined, games ? { games } : undefined);
320
+ const targets = Array.isArray(game.supportedPlatforms) ? game.supportedPlatforms.filter((value) => typeof value === "string") : [];
321
+ // The store page speaks catalog names (ios, android, macos, windows, linux, web) or store ids (macos-universal, ...).
322
+ const catalog = new Set(targets.map((target) => target.replace(/-(universal|x64)$/, "")));
323
+ const unlisted = exportTargets.filter((target) => !catalog.has(target));
324
+ const warnings = unlisted.length
325
+ ? [`The store page of "${game.name ?? gameId}" does not list ${unlisted.join(", ")} (it lists ${targets.join(", ") || "no platforms"}). Players on ${unlisted.join(", ")} will not see this build until the store page lists them: add them with summer_store_update_listing.`]
326
+ : [];
327
+ return { game: { gameId, name: typeof game.name === "string" ? game.name : null, status: typeof game.status === "string" ? game.status : null, targets }, warnings };
275
328
  }
276
329
  export async function publishBuild(input, overrides = {}) {
277
330
  const deps = { ...defaultDependencies, ...overrides };
@@ -288,7 +341,11 @@ export async function publishBuild(input, overrides = {}) {
288
341
  const clientVersion = normalizeClientVersion(input.clientVersion);
289
342
  if (!gameId)
290
343
  return listGamesOrThrow(deps);
291
- return publishDownload({ gameId, file, platform, clientVersion, confirm: input.confirm, waitSeconds: input.waitSeconds, face: input.face }, deps);
344
+ const download = { gameId, file, platform, clientVersion, confirm: input.confirm, waitSeconds: input.waitSeconds, face: input.face };
345
+ if (input.confirm === true)
346
+ return publishDownload(download, deps);
347
+ const preview = await publishDownload(download, deps);
348
+ return { ...preview, game: (await checkStoreGame(gameId, [], deps)).game };
292
349
  }
293
350
  if (extname(file).toLowerCase() !== ".zip") {
294
351
  throw new BuildToolError("export_format_unsupported", `${file} is not a summer.games .zip export.`, "Recovery: export with summer_export_game and upload that .zip.");
@@ -313,23 +370,18 @@ export async function publishBuild(input, overrides = {}) {
313
370
  if (!gameId)
314
371
  return listGamesOrThrow(deps);
315
372
  if (input.confirm !== true) {
373
+ const checked = await checkStoreGame(gameId, platforms, deps);
316
374
  return {
317
375
  ok: true,
318
376
  status: "confirmation_required",
319
377
  target,
320
- next: `Show the user this exact game, file, digest, size, version and publish choice. Only after they approve, call again with confirm=true. Nothing was uploaded.`,
378
+ game: checked.game,
379
+ ...(checked.warnings.length ? { warnings: checked.warnings } : {}),
380
+ next: `Show the user this exact game (name and id), file, digest, size, version and publish choice. Only after they approve, call again with confirm=true. Nothing was uploaded.`,
321
381
  };
322
382
  }
323
383
  const audit = { at: new Date(deps.now()).toISOString(), operation: "publish_build", face: input.face, ...target };
324
- let token;
325
- try {
326
- token = await deps.token();
327
- }
328
- catch (error) {
329
- if (error instanceof OAuthError)
330
- throw new BuildToolError(error.code, error.message.replace(` ${error.recovery}`, ""), error.recovery);
331
- throw error;
332
- }
384
+ const token = await storeToken(deps);
333
385
  const store = createStore(await resolveGatewayUrl(), token, deps);
334
386
  await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "started" });
335
387
  // Same keys Studio derives: the same bytes, version and scene are one declaration.
@@ -49,6 +49,7 @@ import { shapeEngineLogResponse } from "../log-filters.js";
49
49
  import { listCreatorReleases, publishCreator, } from "./creator.js";
50
50
  import { exportGame } from "./export-game.js";
51
51
  import { exportTemplates } from "./export-templates.js";
52
+ import { captureGameplay } from "./capture-gameplay.js";
52
53
  import { publishBuild } from "./publish-build.js";
53
54
  import { CONFIG_KEYS, getConfigValue, isConfigKey, readSummerConfig, resolveGatewayUrl, setConfigValue, unsetConfigValue, } from "../config.js";
54
55
  import { createDebugReportArtifact } from "./debug-report.js";
@@ -630,6 +631,15 @@ export const TOOL_DISPATCH = [
630
631
  summerVersion: optStr(args, "summerVersion"),
631
632
  });
632
633
  }),
634
+ entry("summer_capture_gameplay", "Capture real gameplay frames offscreen, without a running editor", false, (args) => captureGameplay({
635
+ project: optStr(args, "project"),
636
+ scene: optStr(args, "scene"),
637
+ resolution: optStr(args, "resolution"),
638
+ frames: optNumberOrUndefined(args, "frames"),
639
+ waitSeconds: optNumberOrUndefined(args, "waitSeconds"),
640
+ intervalSeconds: optNumberOrUndefined(args, "intervalSeconds"),
641
+ out: optStr(args, "out"),
642
+ })),
633
643
  entry("summer_publish_build", "Upload a summer.games export to your store game (confirm-gated)", false, (args) => publishBuild({
634
644
  gameId: optStr(args, "gameId"),
635
645
  file: optStr(args, "file"),
@@ -30,7 +30,7 @@ function validateGatewayUrl(value) {
30
30
  }
31
31
  const local = parsed.hostname === "localhost" ||
32
32
  parsed.hostname === "127.0.0.1" ||
33
- parsed.hostname === "::1";
33
+ parsed.hostname === "[::1]";
34
34
  if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:")) {
35
35
  throw new Error("gateway.url must use HTTPS (HTTP is allowed only for localhost). Recovery: set an HTTPS URL and retry.");
36
36
  }
@@ -49,7 +49,7 @@ function validateCreatorApiUrl(value) {
49
49
  }
50
50
  const local = parsed.hostname === "localhost" ||
51
51
  parsed.hostname === "127.0.0.1" ||
52
- parsed.hostname === "::1";
52
+ parsed.hostname === "[::1]";
53
53
  if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:")) {
54
54
  throw new Error("creator.apiUrl must use HTTPS (HTTP is allowed only for localhost). Recovery: set an HTTPS URL and retry.");
55
55
  }
@@ -58,6 +58,22 @@ export declare function linuxEngineInstallDir(): string;
58
58
  export declare function macEnginePaths(env?: NodeJS.ProcessEnv): string[];
59
59
  export declare function windowsEnginePaths(env?: NodeJS.ProcessEnv): string[];
60
60
  export declare function linuxEnginePaths(): string[];
61
+ /** Where `summer install` records the engine it installed last
62
+ * (~/.summer/engine-install.json), so an install with --path is found later
63
+ * without SUMMER_BIN. */
64
+ export declare const ENGINE_INSTALL_FILE = "engine-install.json";
65
+ export interface RecordedEngineInstall {
66
+ schemaVersion: 1;
67
+ /** The engine executable (on macOS the one inside Summer.app). */
68
+ binary: string;
69
+ version: string | null;
70
+ installedAt: string;
71
+ }
72
+ export declare function recordEngineInstall(binary: string, version: string | null): Promise<void>;
73
+ /** The engine `summer install` installed last, or null (sync: findEngineBinary is). */
74
+ export declare function recordedEngineBinary(): string | null;
75
+ /** The bundle summer install writes on macOS: <path>/Summer.app, or the path itself when it names a .app. */
76
+ export declare function macInstallDestination(customPath?: string): string;
61
77
  export declare function engineBinaryCandidates(os?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string[];
62
78
  /** First existing engine binary for this machine, or null. SUMMER_BIN /
63
79
  * SUMMER_ENGINE_BINARY wins when set and present; a set-but-missing override
@@ -1,6 +1,7 @@
1
- import { existsSync, statSync } from "fs";
1
+ import { existsSync, readFileSync, statSync } from "fs";
2
2
  import { homedir, platform } from "os";
3
3
  import { basename, join } from "path";
4
+ import { getSummerDir, writeStoreJson } from "./store.js";
4
5
  /**
5
6
  * Where each platform's engine binary lives, shared by `summer install`,
6
7
  * `summer run`, and `summer doctor` so they can never disagree about what
@@ -95,6 +96,31 @@ export function windowsEnginePaths(env = process.env) {
95
96
  export function linuxEnginePaths() {
96
97
  return [join(linuxEngineInstallDir(), LINUX_ENGINE_BINARY_NAME)];
97
98
  }
99
+ /** Where `summer install` records the engine it installed last
100
+ * (~/.summer/engine-install.json), so an install with --path is found later
101
+ * without SUMMER_BIN. */
102
+ export const ENGINE_INSTALL_FILE = "engine-install.json";
103
+ export async function recordEngineInstall(binary, version) {
104
+ const record = { schemaVersion: 1, binary, version, installedAt: new Date().toISOString() };
105
+ await writeStoreJson(ENGINE_INSTALL_FILE, record);
106
+ }
107
+ /** The engine `summer install` installed last, or null (sync: findEngineBinary is). */
108
+ export function recordedEngineBinary() {
109
+ try {
110
+ const record = JSON.parse(readFileSync(join(getSummerDir(), ENGINE_INSTALL_FILE), "utf8"));
111
+ return record.schemaVersion === 1 && typeof record.binary === "string" && record.binary ? record.binary : null;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ /** The bundle summer install writes on macOS: <path>/Summer.app, or the path itself when it names a .app. */
118
+ export function macInstallDestination(customPath) {
119
+ if (!customPath?.trim())
120
+ return "/Applications/Summer.app";
121
+ const trimmed = customPath.trim().replace(/\/+$/, "");
122
+ return /\.app$/i.test(trimmed) ? trimmed : join(trimmed, "Summer.app");
123
+ }
98
124
  export function engineBinaryCandidates(os = platform(), env = process.env) {
99
125
  const override = engineBinaryOverride(env)?.path;
100
126
  const platformPaths = os === "darwin"
@@ -102,7 +128,11 @@ export function engineBinaryCandidates(os = platform(), env = process.env) {
102
128
  : os === "linux"
103
129
  ? linuxEnginePaths()
104
130
  : windowsEnginePaths(env);
105
- return override ? [override, ...platformPaths] : platformPaths;
131
+ // The engine summer install put in place last wins over the default
132
+ // locations (an install with --path next to an older /Applications copy).
133
+ const recorded = recordedEngineBinary();
134
+ const paths = recorded ? [recorded, ...platformPaths.filter((path) => path !== recorded)] : platformPaths;
135
+ return override ? [override, ...paths] : paths;
106
136
  }
107
137
  /** First existing engine binary for this machine, or null. SUMMER_BIN /
108
138
  * SUMMER_ENGINE_BINARY wins when set and present; a set-but-missing override