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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/.cursor-plugin/plugin.json +2 -2
- package/.factory-plugin/plugin.json +2 -2
- package/CHANGELOG.md +30 -0
- package/README.md +27 -12
- package/dist/cli/commands/login.js +12 -0
- package/dist/cli/commands/publish.js +2 -1
- package/dist/core/auth.js +3 -0
- package/dist/core/capabilities/audit/args.d.ts +14 -14
- package/dist/core/capabilities/engine-run.d.ts +8 -0
- package/dist/core/capabilities/engine-run.js +33 -0
- package/dist/core/capabilities/export-game.d.ts +99 -0
- package/dist/core/capabilities/export-game.js +369 -0
- package/dist/core/capabilities/export-presets.d.ts +50 -0
- package/dist/core/capabilities/export-presets.js +177 -0
- package/dist/core/capabilities/export-templates.d.ts +118 -0
- package/dist/core/capabilities/export-templates.js +291 -0
- package/dist/core/capabilities/publish-build.d.ts +64 -0
- package/dist/core/capabilities/publish-build.js +448 -0
- package/dist/core/capabilities/publish-download.d.ts +37 -0
- package/dist/core/capabilities/publish-download.js +168 -0
- package/dist/core/capabilities/runtime-control.d.ts +10 -10
- package/dist/core/capabilities/summer-bundle.d.ts +49 -0
- package/dist/core/capabilities/summer-bundle.js +156 -0
- package/dist/core/capabilities/tool-dispatch.js +33 -1
- package/dist/core/capabilities/ui-control.d.ts +2 -2
- package/dist/core/library-read.d.ts +2 -2
- package/dist/core/oauth.d.ts +78 -0
- package/dist/core/oauth.js +309 -0
- package/dist/core/util/zip-write.d.ts +18 -0
- package/dist/core/util/zip-write.js +112 -0
- package/dist/core/util/zip.d.ts +22 -0
- package/dist/core/util/zip.js +140 -0
- package/dist/mcp/hosted-mount.d.ts +96 -0
- package/dist/mcp/hosted-mount.js +206 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +13 -6
- package/dist/mcp/tools/creator-tools.js +54 -1
- package/gemini-extension.json +1 -1
- package/library/references/mcp-tools-reference/mcp-tools-reference.md +6 -3
- package/library/skills/export-and-ship/SKILL.md +2 -0
- package/library/skills/multiplayer-publish/SKILL.md +5 -1
- package/library/skills/navigate-summer/SKILL.md +1 -1
- package/library/tools/creator-publish/resource.yaml +4 -3
- package/library/tools/creator-releases/resource.yaml +1 -1
- package/library/tools/export-game/resource.yaml +72 -0
- package/library/tools/export-templates/resource.yaml +63 -0
- package/library/tools/open/resource.yaml +1 -1
- package/library/tools/publish-build/resource.yaml +78 -0
- package/package.json +1 -1
- package/registry/generated/aliases.json +3 -0
- package/registry/generated/counts.json +2 -2
- package/registry/generated/gemini-extension.json +1 -1
- package/registry/generated/index.json +119 -10
- package/registry/generated/marketplace.claude.json +2 -2
- package/registry/generated/plugin.claude.json +2 -2
- package/registry/generated/plugin.codex.json +3 -3
- package/registry/generated/plugin.cursor.json +2 -2
- package/registry/generated/plugin.factory.json +2 -2
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { extname, resolve } from "node:path";
|
|
5
|
+
import { resolveGatewayUrl } from "../config.js";
|
|
6
|
+
import { getStoreAccessToken, OAuthError } from "../oauth.js";
|
|
7
|
+
import { appendStoreJsonLine } from "../store.js";
|
|
8
|
+
import { readJsonResponse } from "../util/http.js";
|
|
9
|
+
import { readLastExport } from "./export-game.js";
|
|
10
|
+
import { publishDownload } from "./publish-download.js";
|
|
11
|
+
import { BuildToolError, readSummerBundle } from "./summer-bundle.js";
|
|
12
|
+
/**
|
|
13
|
+
* summer_publish_build: upload a summer.games export to the creator's game
|
|
14
|
+
* through the same creator store routes Studio's export upload uses
|
|
15
|
+
* (publicsummerengine /api/creator-store/* -> summer-platform
|
|
16
|
+
* /v1/management/*, build-publications, ADR 0033):
|
|
17
|
+
*
|
|
18
|
+
* POST games/{g}/build-publications declare the export (idempotent)
|
|
19
|
+
* POST .../{p}:source-upload open the part plan (64 MiB parts)
|
|
20
|
+
* POST .../{p}:signParts presigned PUT per part, bound to its SHA-256
|
|
21
|
+
* PUT <part URL> bytes straight from disk
|
|
22
|
+
* POST .../{p}:source-complete seal it (idempotent)
|
|
23
|
+
* GET .../{p} wait until Summer made the Build
|
|
24
|
+
* POST games/{g}/builds/{b}/client-packages name the Build's client pack
|
|
25
|
+
* POST .../{p}:publish only with publish=true
|
|
26
|
+
*
|
|
27
|
+
* Mirrors publicsummerengine src/lib/creator-store/pack-upload.ts
|
|
28
|
+
* (uploadExportedGame), including its idempotency keys, so a retry continues
|
|
29
|
+
* the same publication instead of creating another one.
|
|
30
|
+
*
|
|
31
|
+
* Auth: the Summer store OAuth token from "summer login --store" (audience
|
|
32
|
+
* mcp.summerengine.com/mcp), sent as a bearer token.
|
|
33
|
+
*/
|
|
34
|
+
const STORE_PROXY_PATH = "/api/creator-store";
|
|
35
|
+
const PART_BYTES = 64 * 1024 * 1024;
|
|
36
|
+
const MAX_SIGNED_PARTS = 100;
|
|
37
|
+
const CONCURRENCY = 3;
|
|
38
|
+
const PART_RETRIES = 3;
|
|
39
|
+
const DEFAULT_WAIT_SECONDS = 600;
|
|
40
|
+
const POLL_MS = 3_000;
|
|
41
|
+
/** summer-platform client versions: vMAJOR.MINOR.PATCH[-rN]. */
|
|
42
|
+
export const CLIENT_VERSION = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-r[1-9]\d*)?$/;
|
|
43
|
+
const INTAKE_DONE = ["preview_ready", "published", "ready", "succeeded"];
|
|
44
|
+
const NOT_ACTIONABLE = "deployment_not_actionable";
|
|
45
|
+
/** Games without a server run on the Summer Games phone apps only (summer-platform gameplatforms.StandaloneTargets). */
|
|
46
|
+
const STANDALONE_TARGETS = ["ios", "android"];
|
|
47
|
+
/** Why the Platform did not take the export, in plain words (same text Studio shows). */
|
|
48
|
+
const INTAKE_FAILURES = {
|
|
49
|
+
game_identity_mismatch: "This export belongs to another game. Upload it to its original game, or make a portable export for this game.",
|
|
50
|
+
bundle_invalid: "Summer could not read this export. Export the game again and upload the new file.",
|
|
51
|
+
declaration_mismatch: "The export's game or settings do not match this upload. Use the original game or export a new version with the intended settings.",
|
|
52
|
+
template_set_unavailable: "Summer Games cannot run this Summer version yet. Update Summer Engine, export again and upload the new file.",
|
|
53
|
+
client_version_conflict: "This version number already belongs to a different export. Use a new clientVersion.",
|
|
54
|
+
artifact_exists: "This exact export is already uploaded for this game. Export a new version and upload that.",
|
|
55
|
+
approval_denied: "Summer Games did not approve this game, so it cannot take new builds.",
|
|
56
|
+
capacity_limit_reached: "Summer Games is busy right now. Try again in a few minutes.",
|
|
57
|
+
};
|
|
58
|
+
export const defaultDependencies = {
|
|
59
|
+
fetch: (input, init) => globalThis.fetch(input, init),
|
|
60
|
+
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
61
|
+
now: Date.now,
|
|
62
|
+
token: () => getStoreAccessToken(),
|
|
63
|
+
};
|
|
64
|
+
export function storeError(status, code, message) {
|
|
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);
|
|
67
|
+
}
|
|
68
|
+
if (status === 403 && code === "owner_approval_required") {
|
|
69
|
+
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);
|
|
70
|
+
}
|
|
71
|
+
if (status === 403) {
|
|
72
|
+
return new BuildToolError("store_forbidden", message || "This account cannot change that game.", "Recovery: check that the game belongs to the signed-in account.", status);
|
|
73
|
+
}
|
|
74
|
+
if (status === 404 && code === "not_found") {
|
|
75
|
+
return new BuildToolError("store_not_found", "The Summer store has no such game, or this part of the store is not open for this account.", "Recovery: check gameId (Studio shows it in the store page URL).", status);
|
|
76
|
+
}
|
|
77
|
+
if (status === 503 || code === "store_unavailable") {
|
|
78
|
+
return new BuildToolError("store_unavailable", message || "Summer Games is not reachable right now.", "Recovery: try again in a few minutes.", status);
|
|
79
|
+
}
|
|
80
|
+
return new BuildToolError(code, INTAKE_FAILURES[code] ?? message ?? `The Summer store refused the request (${status}).`, "Recovery: read the message, fix the export or version, and retry.", status);
|
|
81
|
+
}
|
|
82
|
+
export function createStore(baseUrl, token, deps) {
|
|
83
|
+
return {
|
|
84
|
+
async call(method, path, options = {}) {
|
|
85
|
+
let response;
|
|
86
|
+
try {
|
|
87
|
+
response = await deps.fetch(`${baseUrl}${STORE_PROXY_PATH}/${path}`, {
|
|
88
|
+
method,
|
|
89
|
+
headers: {
|
|
90
|
+
accept: "application/json",
|
|
91
|
+
authorization: `Bearer ${token}`,
|
|
92
|
+
...(options.body === undefined ? {} : { "content-type": "application/json" }),
|
|
93
|
+
...(options.key ? { "idempotency-key": options.key } : {}),
|
|
94
|
+
},
|
|
95
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
96
|
+
redirect: "error",
|
|
97
|
+
signal: AbortSignal.timeout(30_000),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw new BuildToolError("store_network_failed", `Could not reach the Summer store: ${error instanceof Error ? error.message : String(error)}.`, "Recovery: check your network and retry; a retry continues the same upload.");
|
|
102
|
+
}
|
|
103
|
+
const { json } = await readJsonResponse(response);
|
|
104
|
+
const body = (json && typeof json === "object" ? json : {});
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
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);
|
|
108
|
+
}
|
|
109
|
+
return body;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const seg = encodeURIComponent;
|
|
114
|
+
function assertPartUrl(value) {
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(String(value));
|
|
117
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
118
|
+
if (!url.username && !url.password && (url.protocol === "https:" || (local && url.protocol === "http:")))
|
|
119
|
+
return url.toString();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// fall through
|
|
123
|
+
}
|
|
124
|
+
throw new BuildToolError("store_unsafe_upload_url", "The store returned an unsafe upload URL.", "Recovery: nothing was uploaded to it; report the store response as a security issue.");
|
|
125
|
+
}
|
|
126
|
+
/** Whole-file and per-part SHA-256 ("sha256:<hex>") in one pass. */
|
|
127
|
+
export async function hashParts(path, partSize) {
|
|
128
|
+
const whole = createHash("sha256");
|
|
129
|
+
const parts = [];
|
|
130
|
+
let part = createHash("sha256");
|
|
131
|
+
let inPart = 0;
|
|
132
|
+
let sizeBytes = 0;
|
|
133
|
+
for await (const chunk of createReadStream(path, { highWaterMark: 4 * 1024 * 1024 })) {
|
|
134
|
+
let buffer = chunk;
|
|
135
|
+
whole.update(buffer);
|
|
136
|
+
sizeBytes += buffer.length;
|
|
137
|
+
while (buffer.length) {
|
|
138
|
+
const take = Math.min(buffer.length, partSize - inPart);
|
|
139
|
+
part.update(buffer.subarray(0, take));
|
|
140
|
+
inPart += take;
|
|
141
|
+
buffer = buffer.subarray(take);
|
|
142
|
+
if (inPart === partSize) {
|
|
143
|
+
parts.push(`sha256:${part.digest("hex")}`);
|
|
144
|
+
part = createHash("sha256");
|
|
145
|
+
inPart = 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (inPart > 0 || parts.length === 0)
|
|
150
|
+
parts.push(`sha256:${part.digest("hex")}`);
|
|
151
|
+
return { sha256: `sha256:${whole.digest("hex")}`, sizeBytes, parts };
|
|
152
|
+
}
|
|
153
|
+
export function readGrants(body) {
|
|
154
|
+
if (!Array.isArray(body.parts)) {
|
|
155
|
+
throw new BuildToolError("store_invalid_response", "The store did not return part upload URLs.", "Recovery: retry; a retry continues the same upload.");
|
|
156
|
+
}
|
|
157
|
+
return body.parts.map((item) => {
|
|
158
|
+
const grant = item.upload && typeof item.upload === "object" ? { ...item.upload, partNumber: item.partNumber } : item;
|
|
159
|
+
const headers = Object.fromEntries(Object.entries(grant.headers ?? {}).filter((entry) => typeof entry[1] === "string"));
|
|
160
|
+
return {
|
|
161
|
+
partNumber: Number(grant.partNumber),
|
|
162
|
+
url: assertPartUrl(grant.url),
|
|
163
|
+
headers,
|
|
164
|
+
expiresAt: Date.parse(grant.expiresAt) || 0,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async function readPart(path, partNumber, partSize, sizeBytes) {
|
|
169
|
+
const start = (partNumber - 1) * partSize;
|
|
170
|
+
const length = Math.min(sizeBytes, start + partSize) - start;
|
|
171
|
+
const handle = await open(path, "r");
|
|
172
|
+
try {
|
|
173
|
+
const buffer = new Uint8Array(new ArrayBuffer(length));
|
|
174
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
175
|
+
if (bytesRead !== length)
|
|
176
|
+
throw new BuildToolError("export_changed", "The export file changed during upload.", "Recovery: export again and retry.");
|
|
177
|
+
return buffer;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await handle.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Upload the given parts: signed in batches, PUT from disk, re-signed when
|
|
185
|
+
* refused or about to expire. `sign` asks the store for URLs (build
|
|
186
|
+
* publications bind each to its part SHA-256; store versions to its length).
|
|
187
|
+
*/
|
|
188
|
+
export async function uploadParts(sign, file, sizeBytes, plan, deps, parts = Array.from({ length: plan.partCount }, (_, index) => index + 1)) {
|
|
189
|
+
const queue = [...parts];
|
|
190
|
+
const grants = new Map();
|
|
191
|
+
let signing = null;
|
|
192
|
+
const fresh = (grant) => grant !== undefined && grant.expiresAt - deps.now() > 60_000;
|
|
193
|
+
const grantFor = async (partNumber) => {
|
|
194
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
195
|
+
if (fresh(grants.get(partNumber)))
|
|
196
|
+
return grants.get(partNumber);
|
|
197
|
+
if (!signing) {
|
|
198
|
+
const batch = [...new Set([partNumber, ...queue.filter((n) => !fresh(grants.get(n)))])].slice(0, MAX_SIGNED_PARTS);
|
|
199
|
+
signing = sign(batch)
|
|
200
|
+
.then((signed) => {
|
|
201
|
+
for (const grant of signed)
|
|
202
|
+
grants.set(grant.partNumber, grant);
|
|
203
|
+
})
|
|
204
|
+
.finally(() => {
|
|
205
|
+
signing = null;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
await signing;
|
|
209
|
+
}
|
|
210
|
+
throw new BuildToolError("store_sign_failed", `The store did not sign part ${partNumber}.`, "Recovery: retry; a retry continues the same upload.");
|
|
211
|
+
};
|
|
212
|
+
const worker = async () => {
|
|
213
|
+
for (let partNumber = queue.shift(); partNumber !== undefined; partNumber = queue.shift()) {
|
|
214
|
+
const body = await readPart(file, partNumber, plan.partSizeBytes, sizeBytes);
|
|
215
|
+
for (let attempt = 0;; attempt++) {
|
|
216
|
+
const grant = await grantFor(partNumber);
|
|
217
|
+
let status = null;
|
|
218
|
+
try {
|
|
219
|
+
const response = await deps.fetch(grant.url, {
|
|
220
|
+
method: "PUT",
|
|
221
|
+
headers: grant.headers,
|
|
222
|
+
body,
|
|
223
|
+
redirect: "error",
|
|
224
|
+
signal: AbortSignal.timeout(600_000),
|
|
225
|
+
});
|
|
226
|
+
if (response.ok)
|
|
227
|
+
break;
|
|
228
|
+
status = response.status;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
status = null;
|
|
232
|
+
}
|
|
233
|
+
// An expired or refused signature gets a new URL on the next try.
|
|
234
|
+
if (status === 400 || status === 403)
|
|
235
|
+
grants.delete(partNumber);
|
|
236
|
+
if (attempt >= PART_RETRIES) {
|
|
237
|
+
throw new BuildToolError("store_part_upload_failed", `Part ${partNumber} of ${plan.partCount} did not upload${status ? ` (${status})` : ""}.`, "Recovery: retry with the same file and clientVersion; the upload continues where it stopped.");
|
|
238
|
+
}
|
|
239
|
+
await deps.sleep(500 * 2 ** attempt);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
244
|
+
}
|
|
245
|
+
export function normalizeClientVersion(value) {
|
|
246
|
+
const raw = value?.trim() ?? "";
|
|
247
|
+
const version = /^\d/.test(raw) ? `v${raw}` : raw;
|
|
248
|
+
if (!CLIENT_VERSION.test(version)) {
|
|
249
|
+
throw new BuildToolError("client_version_invalid", `clientVersion "${raw}" is not a Summer client version.`, 'Recovery: pass vMAJOR.MINOR.PATCH, for example "v1.0.0" (a re-upload of the same version adds -r2, -r3, ...).');
|
|
250
|
+
}
|
|
251
|
+
return version;
|
|
252
|
+
}
|
|
253
|
+
function targetPlatforms(bundle) {
|
|
254
|
+
if (bundle.hosted)
|
|
255
|
+
return bundle.targetPlatforms;
|
|
256
|
+
const other = bundle.targetPlatforms.filter((platform) => !STANDALONE_TARGETS.includes(platform));
|
|
257
|
+
if (other.length) {
|
|
258
|
+
throw new BuildToolError("platforms_unsupported", `A game without a server runs on iPhone and Android only for now; this export also targets ${other.join(", ")}.`, 'Recovery: export again with summer_export_game targets ["ios"] or ["ios","android"]; ship macOS or Windows as a download (format "download").');
|
|
259
|
+
}
|
|
260
|
+
// The Build declares exactly what the bundle declares (declaration_mismatch otherwise).
|
|
261
|
+
return bundle.targetPlatforms;
|
|
262
|
+
}
|
|
263
|
+
/** gameId is required: the error carries the person's games when the store answers. */
|
|
264
|
+
async function listGamesOrThrow(deps) {
|
|
265
|
+
let games;
|
|
266
|
+
try {
|
|
267
|
+
const store = createStore(await resolveGatewayUrl(), await deps.token(), deps);
|
|
268
|
+
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 }));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
games = undefined;
|
|
273
|
+
}
|
|
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);
|
|
275
|
+
}
|
|
276
|
+
export async function publishBuild(input, overrides = {}) {
|
|
277
|
+
const deps = { ...defaultDependencies, ...overrides };
|
|
278
|
+
const gameId = input.gameId?.trim();
|
|
279
|
+
const fileInput = input.file?.trim();
|
|
280
|
+
const last = await readLastExport();
|
|
281
|
+
const file = fileInput ? resolve(fileInput) : last?.path;
|
|
282
|
+
if (!file) {
|
|
283
|
+
throw new BuildToolError("export_required", "No export to upload.", "Recovery: run summer_export_game first, or pass file with the exported .zip.");
|
|
284
|
+
}
|
|
285
|
+
// The store's second path: a web build or native download becomes a store version.
|
|
286
|
+
const platform = input.platform?.trim() || (last?.path === file ? last.storePlatform : undefined);
|
|
287
|
+
if (platform) {
|
|
288
|
+
const clientVersion = normalizeClientVersion(input.clientVersion);
|
|
289
|
+
if (!gameId)
|
|
290
|
+
return listGamesOrThrow(deps);
|
|
291
|
+
return publishDownload({ gameId, file, platform, clientVersion, confirm: input.confirm, waitSeconds: input.waitSeconds, face: input.face }, deps);
|
|
292
|
+
}
|
|
293
|
+
if (extname(file).toLowerCase() !== ".zip") {
|
|
294
|
+
throw new BuildToolError("export_format_unsupported", `${file} is not a summer.games .zip export.`, "Recovery: export with summer_export_game and upload that .zip.");
|
|
295
|
+
}
|
|
296
|
+
const clientVersion = normalizeClientVersion(input.clientVersion);
|
|
297
|
+
const bundle = await readSummerBundle(file);
|
|
298
|
+
const platforms = targetPlatforms(bundle);
|
|
299
|
+
const digest = await hashParts(file, PART_BYTES);
|
|
300
|
+
const publish = input.publish === true;
|
|
301
|
+
const target = {
|
|
302
|
+
gameId: gameId ?? null,
|
|
303
|
+
file,
|
|
304
|
+
sha256: digest.sha256,
|
|
305
|
+
sizeBytes: digest.sizeBytes,
|
|
306
|
+
clientVersion,
|
|
307
|
+
mainScene: bundle.mainScene,
|
|
308
|
+
targetPlatforms: platforms,
|
|
309
|
+
hosted: bundle.hosted,
|
|
310
|
+
summerVersion: bundle.summerVersion,
|
|
311
|
+
publish,
|
|
312
|
+
};
|
|
313
|
+
if (!gameId)
|
|
314
|
+
return listGamesOrThrow(deps);
|
|
315
|
+
if (input.confirm !== true) {
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
status: "confirmation_required",
|
|
319
|
+
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.`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
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
|
+
}
|
|
333
|
+
const store = createStore(await resolveGatewayUrl(), token, deps);
|
|
334
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "started" });
|
|
335
|
+
// Same keys Studio derives: the same bytes, version and scene are one declaration.
|
|
336
|
+
const key = (write) => createHash("sha256").update(`${gameId}:${clientVersion}:${bundle.mainScene}:${digest.sha256}:${write}`).digest("hex");
|
|
337
|
+
const publications = `games/${seg(gameId)}/build-publications`;
|
|
338
|
+
try {
|
|
339
|
+
const accepted = await store.call("POST", publications, {
|
|
340
|
+
key: key("create:0"),
|
|
341
|
+
body: {
|
|
342
|
+
source: { kind: "exported-game", format: "summer-bundle", archiveSha256: digest.sha256, sizeBytes: digest.sizeBytes },
|
|
343
|
+
clientVersion,
|
|
344
|
+
...(bundle.hostedBuild ? { exportedClient: { entryPoint: bundle.mainScene } } : {}),
|
|
345
|
+
build: bundle.hostedBuild
|
|
346
|
+
? { ...bundle.hostedBuild, version: clientVersion }
|
|
347
|
+
: { version: clientVersion, executionMode: "standalone", targetPlatforms: platforms, client: { entryPoint: bundle.mainScene } },
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
const publicationId = String(accepted.publicationId ?? "");
|
|
351
|
+
if (!publicationId)
|
|
352
|
+
throw new BuildToolError("store_invalid_response", "The store did not return a publication id.", "Recovery: retry; the same upload continues.");
|
|
353
|
+
const publicationPath = `${publications}/${seg(publicationId)}`;
|
|
354
|
+
let publication = await store.call("GET", publicationPath);
|
|
355
|
+
let uploaded = false;
|
|
356
|
+
if (publication.state === "uploading" && publication.progress?.phase !== "inspecting") {
|
|
357
|
+
const opened = await store.call("POST", `${publicationPath}:source-upload`, { body: {} });
|
|
358
|
+
const plan = { partSizeBytes: Number(opened.upload?.partSizeBytes), partCount: Number(opened.upload?.partCount) };
|
|
359
|
+
if (!(plan.partSizeBytes > 0) || plan.partCount !== Math.max(1, Math.ceil(digest.sizeBytes / plan.partSizeBytes))) {
|
|
360
|
+
throw new BuildToolError("store_invalid_response", "The store's part plan does not fit this file.", "Recovery: retry; if it repeats, report the store as unhealthy.");
|
|
361
|
+
}
|
|
362
|
+
const partHashes = plan.partSizeBytes === PART_BYTES ? digest.parts : (await hashParts(file, plan.partSizeBytes)).parts;
|
|
363
|
+
await uploadParts(async (numbers) => readGrants(await store.call("POST", `${publicationPath}:signParts`, { body: { parts: numbers.map((n) => ({ partNumber: n, sha256: partHashes[n - 1] })) } })), file, digest.sizeBytes, plan, deps);
|
|
364
|
+
await store.call("POST", `${publicationPath}:source-complete`, { body: {}, key: key(`seal:${publicationId}`) });
|
|
365
|
+
uploaded = true;
|
|
366
|
+
}
|
|
367
|
+
const deadline = deps.now() + (input.waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000;
|
|
368
|
+
for (;;) {
|
|
369
|
+
publication = await store.call("GET", publicationPath);
|
|
370
|
+
if (INTAKE_DONE.includes(publication.state))
|
|
371
|
+
break;
|
|
372
|
+
if (publication.state === "failed" || publication.state === "cancelled") {
|
|
373
|
+
const code = typeof publication.errorCode === "string" ? publication.errorCode : "publication_failed";
|
|
374
|
+
throw new BuildToolError(code, (code === "declaration_mismatch" && publication.errorMessage) || INTAKE_FAILURES[code] || publication.errorMessage || "Summer could not make a build from this upload.", "Recovery: export again, use a new clientVersion, and retry.", undefined, { publicationId });
|
|
375
|
+
}
|
|
376
|
+
if (deps.now() >= deadline) {
|
|
377
|
+
const result = {
|
|
378
|
+
ok: true,
|
|
379
|
+
status: "processing",
|
|
380
|
+
gameId,
|
|
381
|
+
publicationId,
|
|
382
|
+
buildId: accepted.buildId ?? publication.buildId ?? null,
|
|
383
|
+
state: publication.state,
|
|
384
|
+
uploaded,
|
|
385
|
+
next: "Summer is still checking the export. Call summer_publish_build again with the same file and clientVersion to continue waiting; nothing is uploaded twice.",
|
|
386
|
+
};
|
|
387
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "processing", publicationId });
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
await deps.sleep(POLL_MS);
|
|
391
|
+
}
|
|
392
|
+
const buildId = String(publication.buildId ?? accepted.buildId ?? "");
|
|
393
|
+
let clientPackageId = publication.clientPackage?.id ?? null;
|
|
394
|
+
if (!clientPackageId) {
|
|
395
|
+
// The Build's client pack answers to the same declaration its upload made.
|
|
396
|
+
const named = await store.call("POST", `games/${seg(gameId)}/builds/${seg(buildId)}/client-packages`, {
|
|
397
|
+
body: {
|
|
398
|
+
clientSha256: bundle.clientPack.sha256,
|
|
399
|
+
clientSize: bundle.clientPack.size,
|
|
400
|
+
mainScene: bundle.mainScene,
|
|
401
|
+
version: clientVersion,
|
|
402
|
+
...(bundle.hosted && bundle.compositionPath ? { compositionPath: bundle.compositionPath } : {}),
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
clientPackageId = named.package?.id ?? null;
|
|
406
|
+
}
|
|
407
|
+
let published = false;
|
|
408
|
+
let publishNote = null;
|
|
409
|
+
if (publish) {
|
|
410
|
+
try {
|
|
411
|
+
await store.call("POST", `${publicationPath}:publish`, { body: {}, key: key(`publish:${publicationId}`) });
|
|
412
|
+
published = true;
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (!(error instanceof BuildToolError && error.status === 409 && error.code === NOT_ACTIONABLE))
|
|
416
|
+
throw error;
|
|
417
|
+
publishNote = "The game has not passed review yet, so this Build waits as a preview.";
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "succeeded", publicationId, buildId, published });
|
|
421
|
+
return {
|
|
422
|
+
ok: true,
|
|
423
|
+
status: published ? "published" : "uploaded",
|
|
424
|
+
gameId,
|
|
425
|
+
publicationId,
|
|
426
|
+
buildId,
|
|
427
|
+
clientPackageId,
|
|
428
|
+
state: publication.state,
|
|
429
|
+
clientVersion,
|
|
430
|
+
sha256: digest.sha256,
|
|
431
|
+
sizeBytes: digest.sizeBytes,
|
|
432
|
+
published,
|
|
433
|
+
...(publishNote ? { publishNote } : {}),
|
|
434
|
+
next: published
|
|
435
|
+
? "The Build is approved. Summer checks the client pack per platform; Studio shows the result."
|
|
436
|
+
: "The Build exists as a preview. Summer checks the client pack per platform; publish it from Studio or call again with publish=true.",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
await appendStoreJsonLine("creator-audit.jsonl", {
|
|
441
|
+
...audit,
|
|
442
|
+
outcome: "failed",
|
|
443
|
+
code: error instanceof BuildToolError ? error.code : "publish_build_failed",
|
|
444
|
+
status: error instanceof BuildToolError ? error.status ?? null : null,
|
|
445
|
+
});
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type PublishBuildDependencies } from "./publish-build.js";
|
|
2
|
+
/**
|
|
3
|
+
* The store's second build path (summer-platform store versions,
|
|
4
|
+
* internal/creatorstore): a web build (an HTML5 .zip played on summer.games)
|
|
5
|
+
* or a native download (macOS universal, Windows x64, Linux x64) that players
|
|
6
|
+
* download. summer_export_game format "download" makes these files;
|
|
7
|
+
* summer_publish_build uploads them here, through the same creator store
|
|
8
|
+
* routes as Studio (PSE /api/creator-store/* -> /v1/management/*):
|
|
9
|
+
*
|
|
10
|
+
* GET games/{g}/store/versions?platform= resume an unfinished upload of the same file
|
|
11
|
+
* POST games/{g}/store/versions {platform, fileName, sizeBytes, label} -> part plan
|
|
12
|
+
* GET .../{v}/parts parts storage already has
|
|
13
|
+
* POST .../{v}:signParts {partNumbers} -> presigned PUT per part
|
|
14
|
+
* POST .../{v}:complete seal; Summer checks the file
|
|
15
|
+
* GET .../{v} until ready or rejected
|
|
16
|
+
*
|
|
17
|
+
* Players get the newest ready version of a platform, so on a game that is
|
|
18
|
+
* already live the store refuses an agent's :complete (the owner finishes it).
|
|
19
|
+
*/
|
|
20
|
+
export declare const DOWNLOAD_PLATFORMS: readonly ["web", "macos-universal", "windows-x64", "linux-x64"];
|
|
21
|
+
export type DownloadPlatform = (typeof DOWNLOAD_PLATFORMS)[number];
|
|
22
|
+
export interface PublishDownloadInput {
|
|
23
|
+
gameId: string;
|
|
24
|
+
file: string;
|
|
25
|
+
platform: string;
|
|
26
|
+
clientVersion: string;
|
|
27
|
+
confirm?: boolean;
|
|
28
|
+
waitSeconds?: number;
|
|
29
|
+
face: "cli" | "mcp";
|
|
30
|
+
}
|
|
31
|
+
export declare function isDownloadPlatform(value: string): value is DownloadPlatform;
|
|
32
|
+
/** The store's file name rule: a letter or digit first, then letters, digits, ". _ -" and spaces, at most 128. */
|
|
33
|
+
export declare function storeFileName(file: string): string;
|
|
34
|
+
export declare function publishDownload(input: PublishDownloadInput, deps: PublishBuildDependencies): Promise<Record<string, unknown> & {
|
|
35
|
+
ok: true;
|
|
36
|
+
status: string;
|
|
37
|
+
}>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { basename, extname } from "node:path";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { resolveGatewayUrl } from "../config.js";
|
|
4
|
+
import { OAuthError } from "../oauth.js";
|
|
5
|
+
import { appendStoreJsonLine } from "../store.js";
|
|
6
|
+
import { readZipEntries } from "../util/zip.js";
|
|
7
|
+
import { BuildToolError } from "./summer-bundle.js";
|
|
8
|
+
import { createStore, readGrants, uploadParts } from "./publish-build.js";
|
|
9
|
+
/**
|
|
10
|
+
* The store's second build path (summer-platform store versions,
|
|
11
|
+
* internal/creatorstore): a web build (an HTML5 .zip played on summer.games)
|
|
12
|
+
* or a native download (macOS universal, Windows x64, Linux x64) that players
|
|
13
|
+
* download. summer_export_game format "download" makes these files;
|
|
14
|
+
* summer_publish_build uploads them here, through the same creator store
|
|
15
|
+
* routes as Studio (PSE /api/creator-store/* -> /v1/management/*):
|
|
16
|
+
*
|
|
17
|
+
* GET games/{g}/store/versions?platform= resume an unfinished upload of the same file
|
|
18
|
+
* POST games/{g}/store/versions {platform, fileName, sizeBytes, label} -> part plan
|
|
19
|
+
* GET .../{v}/parts parts storage already has
|
|
20
|
+
* POST .../{v}:signParts {partNumbers} -> presigned PUT per part
|
|
21
|
+
* POST .../{v}:complete seal; Summer checks the file
|
|
22
|
+
* GET .../{v} until ready or rejected
|
|
23
|
+
*
|
|
24
|
+
* Players get the newest ready version of a platform, so on a game that is
|
|
25
|
+
* already live the store refuses an agent's :complete (the owner finishes it).
|
|
26
|
+
*/
|
|
27
|
+
export const DOWNLOAD_PLATFORMS = ["web", "macos-universal", "windows-x64", "linux-x64"];
|
|
28
|
+
/** summer-platform creatorstore limits (versions.go, webzip.go). */
|
|
29
|
+
const WEB_MAX_BYTES = 500 * 1024 * 1024;
|
|
30
|
+
const WEB_MAX_FILES = 2000;
|
|
31
|
+
const WEB_MAX_FILE_BYTES = 200 * 1024 * 1024;
|
|
32
|
+
const NATIVE_MAX_BYTES = 20 * 1024 ** 3;
|
|
33
|
+
const WEB_PART_BYTES = 16 * 1024 * 1024;
|
|
34
|
+
const NATIVE_PART_BYTES = 64 * 1024 * 1024;
|
|
35
|
+
const DEFAULT_WAIT_SECONDS = 300;
|
|
36
|
+
const POLL_MS = 3_000;
|
|
37
|
+
const seg = encodeURIComponent;
|
|
38
|
+
export function isDownloadPlatform(value) {
|
|
39
|
+
return DOWNLOAD_PLATFORMS.includes(value);
|
|
40
|
+
}
|
|
41
|
+
/** The store's file name rule: a letter or digit first, then letters, digits, ". _ -" and spaces, at most 128. */
|
|
42
|
+
export function storeFileName(file) {
|
|
43
|
+
const cleaned = basename(file).replace(/[^A-Za-z0-9._ -]/g, "-").replace(/^[^A-Za-z0-9]+/, "");
|
|
44
|
+
return (cleaned || "game.zip").slice(-128);
|
|
45
|
+
}
|
|
46
|
+
/** What the store will refuse, checked before anything is sent. */
|
|
47
|
+
async function checkFile(file, platform, sizeBytes) {
|
|
48
|
+
const lower = file.toLowerCase();
|
|
49
|
+
if (platform === "web") {
|
|
50
|
+
if (extname(lower) !== ".zip")
|
|
51
|
+
throw new BuildToolError("download_format_unsupported", "A web build is a .zip.", 'Recovery: export with summer_export_game targets ["web"].');
|
|
52
|
+
if (sizeBytes > WEB_MAX_BYTES)
|
|
53
|
+
throw new BuildToolError("download_too_large", `The web build is ${Math.ceil(sizeBytes / 1024 ** 2)} MiB; the store takes at most 500 MiB.`, "Recovery: make the build smaller (compress textures, remove unused assets) and export again.");
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = await readZipEntries(file);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new BuildToolError("download_unreadable", `${file} is not a readable .zip (${error instanceof Error ? error.message : String(error)}).`, "Recovery: export again.");
|
|
60
|
+
}
|
|
61
|
+
const files = entries.filter((entry) => !entry.name.endsWith("/"));
|
|
62
|
+
if (!files.some((entry) => entry.name === "index.html"))
|
|
63
|
+
throw new BuildToolError("web_entry_missing", "The web build has no index.html at the top of the .zip.", 'Recovery: export with summer_export_game targets ["web"]; do not wrap the files in a folder.');
|
|
64
|
+
if (files.length > WEB_MAX_FILES)
|
|
65
|
+
throw new BuildToolError("web_too_many_files", `The web build has ${files.length} files; the store takes at most ${WEB_MAX_FILES}.`, "Recovery: export again; a Summer web export has a handful of files.");
|
|
66
|
+
const big = files.find((entry) => entry.uncompressedSize > WEB_MAX_FILE_BYTES);
|
|
67
|
+
if (big)
|
|
68
|
+
throw new BuildToolError("web_file_too_large", `${big.name} is larger than 200 MiB.`, "Recovery: make the pack smaller and export again.");
|
|
69
|
+
return { fileCount: files.length };
|
|
70
|
+
}
|
|
71
|
+
if (!(lower.endsWith(".zip") || lower.endsWith(".tar.gz") || lower.endsWith(".tgz"))) {
|
|
72
|
+
throw new BuildToolError("download_format_unsupported", "A native download is a .zip or .tar.gz.", 'Recovery: export with summer_export_game format "download".');
|
|
73
|
+
}
|
|
74
|
+
if (sizeBytes > NATIVE_MAX_BYTES)
|
|
75
|
+
throw new BuildToolError("download_too_large", "The download is larger than 20 GiB.", "Recovery: make the build smaller and export again.");
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
export async function publishDownload(input, deps) {
|
|
79
|
+
const { gameId, file, clientVersion } = input;
|
|
80
|
+
if (!isDownloadPlatform(input.platform)) {
|
|
81
|
+
throw new BuildToolError("platform_invalid", `"${input.platform}" is not a store download platform.`, `Recovery: pass platform as one of ${DOWNLOAD_PLATFORMS.join(", ")}.`);
|
|
82
|
+
}
|
|
83
|
+
const platform = input.platform;
|
|
84
|
+
const sizeBytes = (await stat(file)).size;
|
|
85
|
+
const { fileCount } = await checkFile(file, platform, sizeBytes);
|
|
86
|
+
const fileName = storeFileName(file);
|
|
87
|
+
const target = { gameId, file, platform, fileName, sizeBytes, clientVersion, ...(fileCount ? { fileCount } : {}) };
|
|
88
|
+
if (input.confirm !== true) {
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
status: "confirmation_required",
|
|
92
|
+
target,
|
|
93
|
+
next: "Show the user this exact game, file, platform, size and version. Only after they approve, call again with confirm=true. Nothing was uploaded.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
let token;
|
|
97
|
+
try {
|
|
98
|
+
token = await deps.token();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error instanceof OAuthError)
|
|
102
|
+
throw new BuildToolError(error.code, error.message.replace(` ${error.recovery}`, ""), error.recovery);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const store = createStore(await resolveGatewayUrl(), token, deps);
|
|
106
|
+
const audit = { at: new Date(deps.now()).toISOString(), operation: "publish_download", face: input.face, ...target };
|
|
107
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "started" });
|
|
108
|
+
const versions = `games/${seg(gameId)}/store/versions`;
|
|
109
|
+
try {
|
|
110
|
+
// The same file and version still waiting for its bytes: continue it.
|
|
111
|
+
const listed = await store.call("GET", `${versions}?platform=${seg(platform)}`);
|
|
112
|
+
let version = (listed.items ?? []).find((item) => item.status === "awaiting_upload" && item.platform === platform && item.fileName === fileName && item.sizeBytes === sizeBytes && item.label === clientVersion);
|
|
113
|
+
let plan = null;
|
|
114
|
+
const resumed = Boolean(version);
|
|
115
|
+
if (!version) {
|
|
116
|
+
const created = await store.call("POST", versions, {
|
|
117
|
+
body: { platform, fileName, sizeBytes, label: clientVersion },
|
|
118
|
+
});
|
|
119
|
+
version = created.version;
|
|
120
|
+
plan = { partSizeBytes: Number(created.upload?.partSizeBytes), partCount: Number(created.upload?.partCount) };
|
|
121
|
+
}
|
|
122
|
+
if (!version?.id)
|
|
123
|
+
throw new BuildToolError("store_invalid_response", "The store did not return a version id.", "Recovery: retry.");
|
|
124
|
+
const versionPath = `${versions}/${seg(version.id)}`;
|
|
125
|
+
if (version.status === "awaiting_upload") {
|
|
126
|
+
const partSizeBytes = plan?.partSizeBytes || (platform === "web" ? WEB_PART_BYTES : NATIVE_PART_BYTES);
|
|
127
|
+
const partCount = plan?.partCount || Math.max(1, Math.ceil(sizeBytes / partSizeBytes));
|
|
128
|
+
if (!(partSizeBytes > 0) || partCount !== Math.max(1, Math.ceil(sizeBytes / partSizeBytes))) {
|
|
129
|
+
throw new BuildToolError("store_invalid_response", "The store's part plan does not fit this file.", "Recovery: retry; if it repeats, report the store as unhealthy.");
|
|
130
|
+
}
|
|
131
|
+
const expected = (n) => (n < partCount ? partSizeBytes : sizeBytes - (partCount - 1) * partSizeBytes);
|
|
132
|
+
const stored = resumed ? (await store.call("GET", `${versionPath}/parts`)).parts ?? [] : [];
|
|
133
|
+
const done = new Set(stored.filter((part) => part.sizeBytes === expected(part.partNumber)).map((part) => part.partNumber));
|
|
134
|
+
const missing = Array.from({ length: partCount }, (_, index) => index + 1).filter((n) => !done.has(n));
|
|
135
|
+
if (missing.length) {
|
|
136
|
+
await uploadParts(async (numbers) => readGrants(await store.call("POST", `${versionPath}:signParts`, { body: { partNumbers: numbers } })), file, sizeBytes, { partSizeBytes, partCount }, deps, missing);
|
|
137
|
+
}
|
|
138
|
+
version = (await store.call("POST", `${versionPath}:complete`, { body: {} })).version ?? version;
|
|
139
|
+
}
|
|
140
|
+
const deadline = deps.now() + (input.waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000;
|
|
141
|
+
while (version.status !== "ready" && version.status !== "rejected" && deps.now() < deadline) {
|
|
142
|
+
await deps.sleep(POLL_MS);
|
|
143
|
+
version = await store.call("GET", versionPath);
|
|
144
|
+
}
|
|
145
|
+
const outcome = version.status === "ready" ? "ready" : version.status === "rejected" ? "rejected" : "processing";
|
|
146
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome, versionId: version.id });
|
|
147
|
+
if (version.status === "rejected") {
|
|
148
|
+
throw new BuildToolError("download_rejected", version.rejectionReason || "Summer Games did not accept this file.", "Recovery: fix what the reason says, export again and upload with a new clientVersion.", undefined, { versionId: version.id });
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
status: outcome,
|
|
153
|
+
gameId,
|
|
154
|
+
versionId: version.id,
|
|
155
|
+
platform,
|
|
156
|
+
clientVersion,
|
|
157
|
+
resumed,
|
|
158
|
+
...(version.playUrl ? { playUrl: version.playUrl } : {}),
|
|
159
|
+
next: outcome === "ready"
|
|
160
|
+
? "The file is checked. Players get it once the store page is live; submit the page with summer_store_submit (the owner approves)."
|
|
161
|
+
: "Summer is still checking the file. Call summer_publish_build again with the same file and clientVersion to keep waiting; nothing is uploaded twice.",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "failed", code: error instanceof BuildToolError ? error.code : "publish_download_failed" });
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|