summer-engine 3.2.0 → 3.3.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 (61) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +3 -3
  4. package/.cursor-plugin/plugin.json +2 -2
  5. package/.factory-plugin/plugin.json +2 -2
  6. package/CHANGELOG.md +30 -0
  7. package/README.md +27 -12
  8. package/dist/cli/commands/login.js +12 -0
  9. package/dist/cli/commands/publish.js +2 -1
  10. package/dist/core/auth.js +3 -0
  11. package/dist/core/capabilities/audit/args.d.ts +14 -14
  12. package/dist/core/capabilities/engine-run.d.ts +8 -0
  13. package/dist/core/capabilities/engine-run.js +33 -0
  14. package/dist/core/capabilities/export-game.d.ts +99 -0
  15. package/dist/core/capabilities/export-game.js +369 -0
  16. package/dist/core/capabilities/export-presets.d.ts +50 -0
  17. package/dist/core/capabilities/export-presets.js +177 -0
  18. package/dist/core/capabilities/export-templates.d.ts +118 -0
  19. package/dist/core/capabilities/export-templates.js +291 -0
  20. package/dist/core/capabilities/publish-build.d.ts +64 -0
  21. package/dist/core/capabilities/publish-build.js +448 -0
  22. package/dist/core/capabilities/publish-download.d.ts +37 -0
  23. package/dist/core/capabilities/publish-download.js +168 -0
  24. package/dist/core/capabilities/runtime-control.d.ts +10 -10
  25. package/dist/core/capabilities/summer-bundle.d.ts +49 -0
  26. package/dist/core/capabilities/summer-bundle.js +156 -0
  27. package/dist/core/capabilities/tool-dispatch.js +33 -1
  28. package/dist/core/capabilities/ui-control.d.ts +2 -2
  29. package/dist/core/library-read.d.ts +2 -2
  30. package/dist/core/oauth.d.ts +78 -0
  31. package/dist/core/oauth.js +309 -0
  32. package/dist/core/util/zip-write.d.ts +18 -0
  33. package/dist/core/util/zip-write.js +112 -0
  34. package/dist/core/util/zip.d.ts +22 -0
  35. package/dist/core/util/zip.js +140 -0
  36. package/dist/mcp/hosted-mount.d.ts +96 -0
  37. package/dist/mcp/hosted-mount.js +206 -0
  38. package/dist/mcp/server.d.ts +2 -1
  39. package/dist/mcp/server.js +13 -6
  40. package/dist/mcp/tools/creator-tools.js +54 -1
  41. package/gemini-extension.json +1 -1
  42. package/library/references/mcp-tools-reference/mcp-tools-reference.md +6 -3
  43. package/library/skills/export-and-ship/SKILL.md +2 -0
  44. package/library/skills/multiplayer-publish/SKILL.md +5 -1
  45. package/library/skills/navigate-summer/SKILL.md +1 -1
  46. package/library/tools/creator-publish/resource.yaml +4 -3
  47. package/library/tools/creator-releases/resource.yaml +1 -1
  48. package/library/tools/export-game/resource.yaml +72 -0
  49. package/library/tools/export-templates/resource.yaml +63 -0
  50. package/library/tools/open/resource.yaml +1 -1
  51. package/library/tools/publish-build/resource.yaml +78 -0
  52. package/package.json +1 -1
  53. package/registry/generated/aliases.json +3 -0
  54. package/registry/generated/counts.json +2 -2
  55. package/registry/generated/gemini-extension.json +1 -1
  56. package/registry/generated/index.json +119 -10
  57. package/registry/generated/marketplace.claude.json +2 -2
  58. package/registry/generated/plugin.claude.json +2 -2
  59. package/registry/generated/plugin.codex.json +3 -3
  60. package/registry/generated/plugin.cursor.json +2 -2
  61. package/registry/generated/plugin.factory.json +2 -2
@@ -0,0 +1,369 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { platform } from "node:os";
5
+ import { engineSelectionFromEnv } from "../engine.js";
6
+ import { findEngineBinary } from "../engine-install.js";
7
+ import { readStoreJson, writeStoreJson } from "../store.js";
8
+ import { writeZip } from "../util/zip-write.js";
9
+ import { runEngine } from "./engine-run.js";
10
+ import { BUNDLE_TARGETS, DOWNLOAD_TARGETS, SUMMER_GAMES_PRESET, bundlePresetSpec, ensurePreset, normalizeTargets, variantString, } from "./export-presets.js";
11
+ import { engineTemplateInfo, findInstalledTemplate, templateFileFor } from "./export-templates.js";
12
+ import { BuildToolError, hashFile, readSummerBundle } from "./summer-bundle.js";
13
+ export { runEngine, SUMMER_GAMES_PRESET };
14
+ /**
15
+ * summer_export_game: run the installed Summer Engine headless and return the
16
+ * file the Summer Games store takes for the chosen targets.
17
+ *
18
+ * <engine> --headless --path <project> --export-release "<preset>" <out>
19
+ *
20
+ * format "bundle" (default): the summer.bundle.v1 .zip for build-publications
21
+ * (summer_publish_build). Summer runs its client.pck on its own templates in
22
+ * the Summer Games apps, so no template and no signing. Without targets the
23
+ * engine's in-memory "summer.games" preset is used as is (every platform
24
+ * summer.build.json declares); with targets a named summer.games preset ticks
25
+ * exactly those (export-presets.ts).
26
+ *
27
+ * format "download" (one target: web, macos or windows): what the store's
28
+ * versions intake takes. web is a static HTML5 .zip (index.html at the root)
29
+ * built on Summer's WebGPU/JSPI Forward+ template; macos is the .app zipped
30
+ * (macos-universal, ad hoc signed, as the store asks for no signing); windows
31
+ * is the .exe with its pack embedded, zipped (windows-x64). These need the
32
+ * matching export template (summer_export_templates).
33
+ *
34
+ * --headless opens no window.
35
+ */
36
+ export const LAST_EXPORT_FILE = "last-export.json";
37
+ const DEFAULT_TIMEOUT_MS = 15 * 60_000;
38
+ /** Store platform ids of the versions intake (summer-platform creatorstore). */
39
+ const STORE_VERSION_PLATFORM = {
40
+ web: "web",
41
+ macos: "macos-universal",
42
+ windows: "windows-x64",
43
+ };
44
+ /** Web zip limits of the store (summer-platform internal/creatorstore/webzip.go). */
45
+ const WEB_LIMITS = { zipBytes: 500 * 1024 * 1024, fileBytes: 200 * 1024 * 1024, files: 2000 };
46
+ /** Games without a server run on the phone apps only (summer-platform gameplatforms.StandaloneTargets). */
47
+ const STANDALONE_TARGETS = ["ios", "android"];
48
+ /** Engine support per bundle target, for the recovery text when a target is missing. */
49
+ const TARGET_ENGINE_NOTE = {
50
+ android: "Android in a summer.games bundle needs a Summer Engine newer than 0.7.0.",
51
+ };
52
+ const defaultDependencies = {
53
+ findBinary: () => findEngineBinary(platform()),
54
+ run: runEngine,
55
+ now: () => new Date(),
56
+ };
57
+ export function resolveExportProject(project) {
58
+ const root = resolve(project?.trim() || engineSelectionFromEnv()?.projectPath || process.cwd());
59
+ if (!existsSync(join(root, "project.godot"))) {
60
+ throw new BuildToolError("project_not_found", `${root} is not a Summer project (no project.godot).`, "Recovery: pass project with the folder that holds project.godot.");
61
+ }
62
+ return root;
63
+ }
64
+ function stamp(date) {
65
+ return date.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z").replace("T", "-");
66
+ }
67
+ /** <project>/.summer/exports/, ignored by the editor and by git. */
68
+ async function defaultExportDir(project) {
69
+ const dir = join(project, ".summer", "exports");
70
+ await mkdir(dir, { recursive: true });
71
+ for (const [name, body] of [
72
+ [".gdignore", ""],
73
+ [".gitignore", "*\n"],
74
+ ]) {
75
+ const file = join(dir, name);
76
+ if (!existsSync(file))
77
+ await writeFile(file, body);
78
+ }
79
+ return dir;
80
+ }
81
+ function outputTail(output) {
82
+ return output.trim().split("\n").slice(-40).join("\n");
83
+ }
84
+ /** One value from project.godot ([section] key=value), unquoted. */
85
+ export function projectSetting(project, section, key) {
86
+ let current = "";
87
+ for (const line of readFileSync(join(project, "project.godot"), "utf8").split(/\r?\n/)) {
88
+ const header = /^\[([^\]]+)\]\s*$/.exec(line);
89
+ if (header) {
90
+ current = header[1];
91
+ continue;
92
+ }
93
+ if (current !== section || !line.startsWith(`${key}=`))
94
+ continue;
95
+ const value = line.slice(key.length + 1).trim();
96
+ const quoted = /^"((?:[^"\\]|\\.)*)"$/.exec(value);
97
+ return quoted ? quoted[1].replace(/\\(["\\])/g, "$1") : value;
98
+ }
99
+ return null;
100
+ }
101
+ /** Lowercase ascii slug for file names and the macOS bundle id. */
102
+ export function slugify(name) {
103
+ return (name
104
+ .normalize("NFKD")
105
+ .replace(/[^\x00-\x7f]/g, "")
106
+ .toLowerCase()
107
+ .replace(/[^a-z0-9]+/g, "-")
108
+ .replace(/^-+|-+$/g, "")
109
+ .slice(0, 60) || "game");
110
+ }
111
+ function resolveFormat(input, targets) {
112
+ const asked = input.format?.trim();
113
+ if (asked && asked !== "bundle" && asked !== "download") {
114
+ throw new BuildToolError("export_args_invalid", `Unknown format ${asked}.`, 'Recovery: use "bundle" or "download".');
115
+ }
116
+ if (asked === "download" || (!asked && targets?.includes("web"))) {
117
+ if (!targets || targets.length !== 1 || !DOWNLOAD_TARGETS.includes(targets[0])) {
118
+ throw new BuildToolError("export_args_invalid", "A download export makes one file for one target: web, macos or windows.", 'Recovery: pass exactly one of targets ["web"], ["macos"] or ["windows"]; iOS and Android are store builds only (format "bundle").');
119
+ }
120
+ return "download";
121
+ }
122
+ const notBundle = (targets ?? []).filter((target) => !BUNDLE_TARGETS.includes(target));
123
+ if (notBundle.length) {
124
+ throw new BuildToolError("export_args_invalid", `A summer.games bundle has no ${notBundle.join(", ")} client: the store takes web as a separate web build.`, 'Recovery: export web on its own with targets ["web"] (format "download").');
125
+ }
126
+ if (input.preset?.trim() && targets) {
127
+ throw new BuildToolError("export_args_invalid", "Pass targets or preset, not both: targets picks its own summer.games preset.", "Recovery: drop preset, or drop targets.");
128
+ }
129
+ return "bundle";
130
+ }
131
+ /** The rendering method the web build runs: the .web override, else the project's (Godot's default is Forward+). */
132
+ function webRenderer(project) {
133
+ return (projectSetting(project, "rendering", "renderer/rendering_method.web") ??
134
+ projectSetting(project, "rendering", "renderer/rendering_method") ??
135
+ "forward_plus");
136
+ }
137
+ function downloadPresetSpec(target, slug) {
138
+ if (target === "web") {
139
+ return {
140
+ name: "Summer download Web",
141
+ platform: "Web",
142
+ options: {
143
+ // Summer's WebGPU/JSPI template: Forward+ or Mobile in the browser,
144
+ // no threads (so summer.games can frame it), no GDExtension.
145
+ "variant/runtime_profile": variantString("summer-web-jspi-v1"),
146
+ "variant/extensions_support": "false",
147
+ "variant/thread_support": "false",
148
+ "html/export_icon": "true",
149
+ },
150
+ };
151
+ }
152
+ if (target === "macos") {
153
+ return {
154
+ name: "Summer download macOS",
155
+ platform: "macOS",
156
+ options: {
157
+ "binary_format/architecture": variantString("universal"),
158
+ "application/bundle_identifier": variantString(`games.summer.${slug}`),
159
+ // Built-in ad hoc signing works from every OS; the store needs no
160
+ // Developer ID or notarization.
161
+ "codesign/codesign": "1",
162
+ "notarization/notarization": "0",
163
+ },
164
+ };
165
+ }
166
+ return {
167
+ name: "Summer download Windows",
168
+ platform: "Windows Desktop",
169
+ options: {
170
+ "binary_format/architecture": variantString("x86_64"),
171
+ // One .exe: the store takes one archive per platform.
172
+ "binary_format/embed_pck": "true",
173
+ "codesign/enable": "false",
174
+ },
175
+ };
176
+ }
177
+ async function listFiles(root) {
178
+ const files = [];
179
+ const walk = async (dir) => {
180
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
181
+ const path = join(dir, entry.name);
182
+ if (entry.isDirectory())
183
+ await walk(path);
184
+ else if (entry.isFile())
185
+ files.push(path);
186
+ }
187
+ };
188
+ await walk(root);
189
+ return files.sort();
190
+ }
191
+ async function runExport(deps, binary, project, preset, out, debug, timeoutMs, notes) {
192
+ const args = ["--headless", "--path", project, debug ? "--export-debug" : "--export-release", preset, out];
193
+ let run;
194
+ try {
195
+ run = await deps.run(binary, args, timeoutMs);
196
+ }
197
+ catch (error) {
198
+ throw new BuildToolError("engine_launch_failed", `Summer Engine could not start: ${error instanceof Error ? error.message : String(error)}.`, 'Recovery: run "summer doctor" and check the engine install.');
199
+ }
200
+ if (run.timedOut) {
201
+ 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
+ }
203
+ 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) });
208
+ }
209
+ }
210
+ export async function exportGame(input, overrides = {}) {
211
+ const deps = { ...defaultDependencies, ...overrides };
212
+ const project = resolveExportProject(input.project);
213
+ const targets = input.targets?.length ? normalizeTargets(input.targets) : undefined;
214
+ const format = resolveFormat(input, targets);
215
+ const debug = input.debug === true;
216
+ const started = deps.now();
217
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
218
+ let out = null;
219
+ if (input.out?.trim()) {
220
+ out = isAbsolute(input.out) ? input.out : resolve(project, input.out);
221
+ if (extname(out).toLowerCase() !== ".zip") {
222
+ throw new BuildToolError("export_path_invalid", `The export path ${out} must end in .zip: the store takes a .zip.`, "Recovery: pass out ending in .zip, or omit it.");
223
+ }
224
+ await mkdir(dirname(out), { recursive: true });
225
+ }
226
+ const binary = deps.findBinary();
227
+ if (!binary) {
228
+ 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
+ }
230
+ if (format === "download") {
231
+ return exportDownload(deps, binary, project, targets[0], out, debug, timeoutMs, started);
232
+ }
233
+ out ??= join(await defaultExportDir(project), `${basename(project)}-${stamp(started)}.zip`);
234
+ const before = existsSync(out) ? statSync(out).mtimeMs : null;
235
+ const ensured = targets ? await ensurePreset(project, bundlePresetSpec(targets)) : undefined;
236
+ const preset = ensured?.name ?? (input.preset?.trim() || SUMMER_GAMES_PRESET);
237
+ const notes = (targets ?? []).flatMap((target) => TARGET_ENGINE_NOTE[target] ?? []);
238
+ await runExport(deps, binary, project, preset, out, debug, timeoutMs, notes);
239
+ if (before !== null && statSync(out).mtimeMs === before) {
240
+ throw new BuildToolError("export_failed", "Summer Engine exited without writing a new bundle.", "Recovery: read the engine output and export again.");
241
+ }
242
+ const bundle = await readSummerBundle(out);
243
+ const missing = (targets ?? []).filter((target) => !bundle.targetPlatforms.includes(target));
244
+ if (missing.length) {
245
+ 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
+ }
247
+ const warnings = [];
248
+ const desktopOnly = bundle.hosted ? [] : bundle.targetPlatforms.filter((target) => !STANDALONE_TARGETS.includes(target));
249
+ if (desktopOnly.length) {
250
+ 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(", ")}. ` +
251
+ 'Export targets ["ios","android"] for the store build, and macOS or Windows as a download (format "download").');
252
+ }
253
+ const { sha256, sizeBytes } = await hashFile(out);
254
+ const last = { path: out, project, sha256, sizeBytes, exportedAt: deps.now().toISOString() };
255
+ await writeStoreJson(LAST_EXPORT_FILE, last);
256
+ const { hostedBuild: _hostedBuild, files, ...summary } = bundle;
257
+ return {
258
+ ok: true,
259
+ format,
260
+ path: out,
261
+ sha256,
262
+ sizeBytes,
263
+ project,
264
+ preset,
265
+ ...(targets ? { targets, presetChange: ensured.change } : {}),
266
+ debug,
267
+ engine: binary,
268
+ durationMs: deps.now().getTime() - started.getTime(),
269
+ bundle: { ...summary, fileCount: files.length },
270
+ ...(warnings.length ? { warnings } : {}),
271
+ next: "Upload it to the game's store listing with summer_publish_build (gameId, clientVersion).",
272
+ };
273
+ }
274
+ async function exportDownload(deps, binary, project, target, requestedOut, debug, timeoutMs, started) {
275
+ const name = projectSetting(project, "application", "config/name") || basename(project);
276
+ const slug = slugify(name);
277
+ const storePlatform = STORE_VERSION_PLATFORM[target];
278
+ if (target === "web") {
279
+ const renderer = webRenderer(project);
280
+ if (renderer !== "forward_plus" && renderer !== "mobile") {
281
+ throw new BuildToolError("web_renderer_unsupported", `The project renders with ${renderer}; Summer's web template runs Forward+ or Mobile only.`, 'Recovery: set Project Settings > Rendering > Renderer > Rendering Method (or its .web override) to "forward_plus", then export again.');
282
+ }
283
+ }
284
+ // Name the template the engine will need, before a long export fails on it.
285
+ const templateFile = templateFileFor(target, debug);
286
+ try {
287
+ const info = await engineTemplateInfo({ findBinary: () => binary, run: deps.run, ...deps.templates });
288
+ if (!findInstalledTemplate(info, templateFile)) {
289
+ throw new BuildToolError("export_template_missing", `The ${target} export needs the template ${templateFile}, and Summer Engine has none in ${info.searchDirs.join(" or ")}.`, `Recovery: call summer_export_templates with action "install" and platforms ["${target}"]${debug ? " and includeDebug true" : ""}, then export again.`, undefined, { template: templateFile, folder: info.folder });
290
+ }
291
+ }
292
+ catch (error) {
293
+ // Only a missing template stops here; if the version probe fails, the
294
+ // engine's own export check still names what is wrong.
295
+ if (error instanceof BuildToolError && error.code === "export_template_missing")
296
+ throw error;
297
+ }
298
+ const spec = downloadPresetSpec(target, slug);
299
+ const ensured = await ensurePreset(project, spec);
300
+ const exportsDir = await defaultExportDir(project);
301
+ const work = join(exportsDir, `${slug}-${target}-${stamp(started)}`);
302
+ await rm(work, { recursive: true, force: true });
303
+ await mkdir(work, { recursive: true });
304
+ const out = requestedOut ?? join(exportsDir, `${slug}-${storePlatform}-${stamp(started)}.zip`);
305
+ const warnings = [];
306
+ 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
+ }
327
+ }
328
+ else {
329
+ warnings.push("The .exe is not code signed; Windows SmartScreen may warn players. The store accepts it as is.");
330
+ }
331
+ await writeZip(out, entries);
332
+ fileCount = entries.length;
333
+ }
334
+ }
335
+ finally {
336
+ await rm(work, { recursive: true, force: true });
337
+ }
338
+ const icon = projectSetting(project, "application", "config/icon");
339
+ if (!icon)
340
+ warnings.push("The project has no icon (Project Settings > Application > Config > Icon); the export uses the Summer default.");
341
+ const { sha256, sizeBytes } = await hashFile(out);
342
+ const last = { path: out, project, sha256, sizeBytes, exportedAt: deps.now().toISOString(), storePlatform };
343
+ await writeStoreJson(LAST_EXPORT_FILE, last);
344
+ return {
345
+ ok: true,
346
+ format: "download",
347
+ path: out,
348
+ sha256,
349
+ sizeBytes,
350
+ project,
351
+ preset: spec.name,
352
+ targets: [target],
353
+ presetChange: ensured.change,
354
+ debug,
355
+ engine: binary,
356
+ durationMs: deps.now().getTime() - started.getTime(),
357
+ storePlatform,
358
+ fileCount,
359
+ icon,
360
+ signing: target === "macos" ? "ad hoc (built-in), not notarized" : "none",
361
+ ...(warnings.length ? { warnings } : {}),
362
+ next: `Upload it as the game's ${storePlatform} store version with summer_publish_build (gameId, clientVersion); it reads the platform from this export.`,
363
+ };
364
+ }
365
+ /** The file the last summer_export_game wrote (bundle or download), when it is still on disk. */
366
+ export async function readLastExport() {
367
+ const last = await readStoreJson(LAST_EXPORT_FILE).catch(() => null);
368
+ return last && typeof last.path === "string" && existsSync(last.path) ? last : null;
369
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Store targets for summer_export_game, and the export presets that make them.
3
+ *
4
+ * The Summer Games store has two intakes (summer-platform):
5
+ * - build-publications takes ONE summer.bundle.v1 .zip per Build. Summer runs
6
+ * its client.pck on its own client templates (the Summer Games iOS and
7
+ * Android apps, the Summer desktop app on macOS and Windows), so the creator
8
+ * needs no export template and signs nothing. A target only picks the
9
+ * platforms the bundle declares and the texture formats the pack carries
10
+ * (the summer.games preset option platforms/<id>).
11
+ * - store versions take a web build (a static HTML5 .zip played in an iframe)
12
+ * and native desktop downloads (macos-universal, windows-x64). These are
13
+ * ordinary exports and need the matching export template.
14
+ *
15
+ * Presets are kept in export_presets.cfg under fixed names, so the creator
16
+ * sees them in the Export dialog: "summer.games ios", "summer.games
17
+ * ios+android", "Summer download Web", ... Only the options listed here are
18
+ * ever rewritten; every other preset and setting stays byte-for-byte. The
19
+ * engine adds its default "summer.games" preset only while the file has no
20
+ * summer.games preset, so the default is written alongside the first named
21
+ * one.
22
+ */
23
+ export declare const STORE_TARGETS: readonly ["macos", "windows", "ios", "android", "web"];
24
+ export type StoreTarget = (typeof STORE_TARGETS)[number];
25
+ /** Targets a summer.games bundle can declare (the engine's CLIENT_PLATFORMS). */
26
+ export declare const BUNDLE_TARGETS: readonly StoreTarget[];
27
+ /** Targets the store takes as a download or web build (store versions). */
28
+ export declare const DOWNLOAD_TARGETS: readonly StoreTarget[];
29
+ /** Engine summer.games platform and default preset name (summer_games_export_platform.h PLATFORM_NAME). */
30
+ export declare const SUMMER_GAMES_PLATFORM = "summer.games";
31
+ export declare const SUMMER_GAMES_PRESET = "summer.games";
32
+ /** Canonical order, without repeats. */
33
+ export declare function normalizeTargets(targets: readonly string[]): StoreTarget[];
34
+ /** A preset the MCP owns: its name, export platform and the options it sets (Godot variant text). */
35
+ export interface PresetSpec {
36
+ name: string;
37
+ platform: string;
38
+ customFeatures?: string;
39
+ options: Record<string, string>;
40
+ }
41
+ /** Variant text for a String option. */
42
+ export declare function variantString(value: string): string;
43
+ export declare function bundlePresetSpec(targets: readonly StoreTarget[]): PresetSpec;
44
+ export interface EnsuredPreset {
45
+ name: string;
46
+ /** "created", "updated" (owned options rewritten) or "unchanged". */
47
+ change: "created" | "updated" | "unchanged";
48
+ }
49
+ /** Make sure export_presets.cfg has this preset with these option values. */
50
+ export declare function ensurePreset(project: string, spec: PresetSpec): Promise<EnsuredPreset>;
@@ -0,0 +1,177 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { BuildToolError } from "./summer-bundle.js";
5
+ /**
6
+ * Store targets for summer_export_game, and the export presets that make them.
7
+ *
8
+ * The Summer Games store has two intakes (summer-platform):
9
+ * - build-publications takes ONE summer.bundle.v1 .zip per Build. Summer runs
10
+ * its client.pck on its own client templates (the Summer Games iOS and
11
+ * Android apps, the Summer desktop app on macOS and Windows), so the creator
12
+ * needs no export template and signs nothing. A target only picks the
13
+ * platforms the bundle declares and the texture formats the pack carries
14
+ * (the summer.games preset option platforms/<id>).
15
+ * - store versions take a web build (a static HTML5 .zip played in an iframe)
16
+ * and native desktop downloads (macos-universal, windows-x64). These are
17
+ * ordinary exports and need the matching export template.
18
+ *
19
+ * Presets are kept in export_presets.cfg under fixed names, so the creator
20
+ * sees them in the Export dialog: "summer.games ios", "summer.games
21
+ * ios+android", "Summer download Web", ... Only the options listed here are
22
+ * ever rewritten; every other preset and setting stays byte-for-byte. The
23
+ * engine adds its default "summer.games" preset only while the file has no
24
+ * summer.games preset, so the default is written alongside the first named
25
+ * one.
26
+ */
27
+ export const STORE_TARGETS = ["macos", "windows", "ios", "android", "web"];
28
+ /** Targets a summer.games bundle can declare (the engine's CLIENT_PLATFORMS). */
29
+ export const BUNDLE_TARGETS = ["macos", "windows", "ios", "android"];
30
+ /** Targets the store takes as a download or web build (store versions). */
31
+ export const DOWNLOAD_TARGETS = ["macos", "windows", "web"];
32
+ /** Engine summer.games platform and default preset name (summer_games_export_platform.h PLATFORM_NAME). */
33
+ export const SUMMER_GAMES_PLATFORM = "summer.games";
34
+ export const SUMMER_GAMES_PRESET = "summer.games";
35
+ /** summer_games_export_platform.h CLIENT_FEATURE; create_preset sets it, the file must keep it. */
36
+ const SUMMER_CLIENT_FEATURE = "summer_client";
37
+ const PRESETS_FILE = "export_presets.cfg";
38
+ /** Canonical order, without repeats. */
39
+ export function normalizeTargets(targets) {
40
+ const unknown = targets.filter((target) => !STORE_TARGETS.includes(target));
41
+ if (unknown.length) {
42
+ throw new BuildToolError("target_unknown", `Unknown export target ${unknown.join(", ")}.`, `Recovery: use targets from ${STORE_TARGETS.join(", ")}.`);
43
+ }
44
+ const chosen = STORE_TARGETS.filter((target) => targets.includes(target));
45
+ if (!chosen.length) {
46
+ throw new BuildToolError("target_unknown", "No export target was given.", `Recovery: pass targets from ${STORE_TARGETS.join(", ")}.`);
47
+ }
48
+ return chosen;
49
+ }
50
+ /** Variant text for a String option. */
51
+ export function variantString(value) {
52
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
53
+ }
54
+ export function bundlePresetSpec(targets) {
55
+ return {
56
+ name: `${SUMMER_GAMES_PRESET} ${targets.join("+")}`,
57
+ platform: SUMMER_GAMES_PLATFORM,
58
+ customFeatures: SUMMER_CLIENT_FEATURE,
59
+ // Every engine platform is written, so a platform added by a newer engine
60
+ // never turns on by default in a preset made for other targets.
61
+ options: Object.fromEntries(STORE_TARGETS.map((target) => [`platforms/${target}`, String(targets.includes(target))])),
62
+ };
63
+ }
64
+ function parseSections(text) {
65
+ const preamble = [];
66
+ const sections = [];
67
+ for (const line of text.split(/\r?\n/)) {
68
+ const header = /^\[([^\]]+)\]\s*$/.exec(line);
69
+ if (header)
70
+ sections.push({ header: header[1], lines: [] });
71
+ else if (sections.length)
72
+ sections[sections.length - 1].lines.push(line);
73
+ else
74
+ preamble.push(line);
75
+ }
76
+ return { preamble, sections };
77
+ }
78
+ function stringValue(section, key) {
79
+ const prefix = `${key}=`;
80
+ for (const line of section.lines) {
81
+ if (!line.startsWith(prefix))
82
+ continue;
83
+ const match = /^"((?:[^"\\]|\\.)*)"\s*$/.exec(line.slice(prefix.length));
84
+ if (match)
85
+ return match[1].replace(/\\(["\\])/g, "$1");
86
+ }
87
+ return null;
88
+ }
89
+ function presetLines(spec) {
90
+ return [
91
+ "",
92
+ `name=${variantString(spec.name)}`,
93
+ `platform=${variantString(spec.platform)}`,
94
+ "runnable=false",
95
+ "dedicated_server=false",
96
+ ...(spec.customFeatures !== undefined ? [`custom_features=${variantString(spec.customFeatures)}`] : []),
97
+ 'export_filter="all_resources"',
98
+ 'include_filter=""',
99
+ 'exclude_filter=""',
100
+ 'export_path=""',
101
+ "",
102
+ ];
103
+ }
104
+ function optionLines(options) {
105
+ return ["", ...Object.entries(options).map(([key, value]) => `${key}=${value}`), ""];
106
+ }
107
+ /** Make sure export_presets.cfg has this preset with these option values. */
108
+ export async function ensurePreset(project, spec) {
109
+ const file = join(project, PRESETS_FILE);
110
+ const text = existsSync(file) ? await readFile(file, "utf8") : "";
111
+ const { preamble, sections } = parseSections(text);
112
+ const presets = sections
113
+ .map((section) => ({ section, index: /^preset\.(\d+)$/.exec(section.header)?.[1] }))
114
+ .filter((item) => item.index !== undefined);
115
+ const ours = presets.find(({ section }) => stringValue(section, "name") === spec.name);
116
+ if (ours) {
117
+ if (stringValue(ours.section, "platform") !== spec.platform) {
118
+ throw new BuildToolError("export_preset_conflict", `export_presets.cfg already has a preset named "${spec.name}" for another platform.`, "Recovery: rename or delete that preset in Project > Export, then export again.");
119
+ }
120
+ const optionsHeader = `preset.${ours.index}.options`;
121
+ let options = sections.find((section) => section.header === optionsHeader);
122
+ const wanted = new Map(Object.entries(spec.options));
123
+ let changed = false;
124
+ if (!options) {
125
+ options = { header: optionsHeader, lines: optionLines(spec.options) };
126
+ sections.splice(sections.indexOf(ours.section) + 1, 0, options);
127
+ changed = true;
128
+ }
129
+ else {
130
+ const seen = new Set();
131
+ options.lines = options.lines.map((line) => {
132
+ const at = line.indexOf("=");
133
+ const key = at > 0 ? line.slice(0, at) : "";
134
+ if (!wanted.has(key))
135
+ return line;
136
+ seen.add(key);
137
+ if (line.slice(at + 1).trim() === wanted.get(key))
138
+ return line;
139
+ changed = true;
140
+ return `${key}=${wanted.get(key)}`;
141
+ });
142
+ const missing = [...wanted].filter(([key]) => !seen.has(key)).map(([key, value]) => `${key}=${value}`);
143
+ if (missing.length) {
144
+ const end = options.lines.length && options.lines[options.lines.length - 1] === "" ? options.lines.length - 1 : options.lines.length;
145
+ options.lines.splice(end, 0, ...missing);
146
+ changed = true;
147
+ }
148
+ }
149
+ if (!changed)
150
+ return { name: spec.name, change: "unchanged" };
151
+ await writeFile(file, render(preamble, sections));
152
+ return { name: spec.name, change: "updated" };
153
+ }
154
+ let next = presets.reduce((max, { index }) => Math.max(max, Number(index) + 1), 0);
155
+ const added = [];
156
+ const hasSummerGames = presets.some(({ section }) => stringValue(section, "platform") === SUMMER_GAMES_PLATFORM);
157
+ if (spec.platform === SUMMER_GAMES_PLATFORM && !hasSummerGames) {
158
+ // Keep the engine's default "summer.games" preset: it leaves the editor
159
+ // once the file holds any summer.games preset. No options section, so the
160
+ // engine fills in its own defaults.
161
+ added.push({
162
+ header: `preset.${next}`,
163
+ lines: presetLines({ name: SUMMER_GAMES_PRESET, platform: SUMMER_GAMES_PLATFORM, customFeatures: SUMMER_CLIENT_FEATURE, options: {} }),
164
+ });
165
+ next += 1;
166
+ }
167
+ added.push({ header: `preset.${next}`, lines: presetLines(spec) });
168
+ added.push({ header: `preset.${next}.options`, lines: optionLines(spec.options) });
169
+ await writeFile(file, render(preamble, [...sections, ...added]));
170
+ return { name: spec.name, change: "created" };
171
+ }
172
+ function render(preamble, sections) {
173
+ const body = sections.map((section) => [`[${section.header}]`, ...section.lines].join("\n")).join("\n");
174
+ const head = preamble.join("\n");
175
+ const joined = head.trim() ? `${head.replace(/\n*$/, "\n")}\n${body}` : body;
176
+ return joined.endsWith("\n") ? joined : `${joined}\n`;
177
+ }