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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +5 -1
- package/.codex-plugin/plugin.json +5 -1
- package/.cursor-plugin/plugin.json +5 -1
- package/.factory-plugin/plugin.json +5 -1
- package/CHANGELOG.md +31 -0
- package/README.md +5 -1
- package/dist/cli/commands/setup.js +24 -4
- package/dist/core/capabilities/capture-gameplay.d.ts +72 -7
- package/dist/core/capabilities/capture-gameplay.js +386 -25
- package/dist/core/capabilities/export-engine-version.d.ts +7 -0
- package/dist/core/capabilities/export-engine-version.js +9 -0
- package/dist/core/capabilities/export-game.d.ts +4 -0
- package/dist/core/capabilities/export-game.js +50 -7
- package/dist/core/capabilities/image-generation.d.ts +12 -2
- package/dist/core/capabilities/image-generation.js +31 -2
- package/dist/core/capabilities/publish-build.d.ts +4 -0
- package/dist/core/capabilities/publish-build.js +84 -37
- package/dist/core/capabilities/runtime-control.d.ts +2 -2
- package/dist/core/capabilities/summer-bundle.d.ts +9 -0
- package/dist/core/capabilities/summer-bundle.js +19 -0
- package/dist/core/capabilities/tool-dispatch.js +13 -2
- package/dist/core/library-read.d.ts +7 -0
- package/dist/core/library-read.js +59 -7
- package/dist/core/library-search.d.ts +2 -0
- package/dist/installer/agent-config.d.ts +26 -1
- package/dist/installer/agent-config.js +180 -5
- package/dist/installer/agent-table.d.ts +13 -0
- package/dist/installer/agent-table.js +120 -0
- package/dist/mcp/tools/creator-tools.js +32 -3
- package/dist/mcp/tools/generate-tools.js +11 -30
- package/dist/mcp/tools/library-tools.d.ts +2 -1
- package/dist/mcp/tools/library-tools.js +1 -0
- package/gemini-extension.json +5 -1
- package/integrations/README.md +6 -0
- package/library/references/mcp-tools-reference/mcp-tools-reference.md +1 -1
- package/library/skills/README.md +11 -1
- package/library/skills/export-and-ship/SKILL.md +1 -1
- package/library/skills/export-and-ship/resource.yaml +2 -1
- package/library/skills/grow-analytics/SKILL.md +21 -0
- package/library/skills/grow-analytics/resource.yaml +27 -0
- package/library/skills/publish-your-game/SKILL.md +21 -0
- package/library/skills/publish-your-game/resource.yaml +29 -0
- package/library/skills/store-art/SKILL.md +23 -0
- package/library/skills/store-art/resource.yaml +30 -0
- package/library/skills/store-listing/SKILL.md +21 -0
- package/library/skills/store-listing/resource.yaml +25 -0
- package/library/skills/summer-analytics/resource.yaml +1 -0
- package/library/tools/capture-gameplay/resource.yaml +66 -1
- package/library/tools/export-game/resource.yaml +3 -0
- package/library/tools/generate-image/resource.yaml +4 -2
- package/package.json +1 -1
- package/registry/generated/counts.json +2 -2
- package/registry/generated/gemini-extension.json +5 -1
- package/registry/generated/index.json +153 -10
- package/registry/generated/marketplace.claude.json +1 -1
- package/registry/generated/plugin.claude.json +5 -1
- package/registry/generated/plugin.codex.json +5 -1
- package/registry/generated/plugin.cursor.json +5 -1
- package/registry/generated/plugin.factory.json +5 -1
- package/registry/generated/skills-index.md +11 -1
- package/registry/generated/skills-registry.json +52 -0
- 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
|
-
\
|
|
125
|
-
\
|
|
126
|
-
\t\
|
|
127
|
-
\t\
|
|
128
|
-
\t\
|
|
129
|
-
\t\
|
|
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
|
|
140
|
-
|
|
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
|
|
189
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|