summer-engine 4.3.0 → 4.6.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 +1 -1
- package/.claude-plugin/plugin.json +3 -1
- package/.codex-plugin/plugin.json +5 -3
- package/.cursor-plugin/plugin.json +4 -2
- package/.factory-plugin/plugin.json +4 -2
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +100 -0
- package/README.md +67 -27
- package/commands/start.md +19 -15
- package/commands/summer.md +1 -0
- package/dist/cli/command-usage.d.ts +3 -1
- package/dist/cli/command-usage.js +10 -4
- package/dist/cli/commands/create.d.ts +1 -25
- package/dist/cli/commands/create.js +10 -138
- package/dist/cli/commands/dev.d.ts +66 -0
- package/dist/cli/commands/dev.js +234 -0
- package/dist/cli/commands/install.d.ts +15 -58
- package/dist/cli/commands/install.js +31 -481
- package/dist/cli/commands/login.js +1 -1
- package/dist/cli/commands/ops.d.ts +17 -1
- package/dist/cli/commands/ops.js +97 -9
- package/dist/cli/commands/preview.d.ts +58 -0
- package/dist/cli/commands/preview.js +181 -0
- package/dist/cli/commands/run.d.ts +1 -17
- package/dist/cli/commands/run.js +17 -206
- package/dist/cli/commands/setup.js +175 -32
- package/dist/cli/index.js +5 -0
- package/dist/core/agent-host.d.ts +3 -0
- package/dist/core/agent-host.js +1 -1
- package/dist/core/api-client.d.ts +10 -2
- package/dist/core/api-client.js +13 -8
- package/dist/core/capabilities/agent-playbook.js +1 -0
- package/dist/core/capabilities/apps.d.ts +9 -9
- package/dist/core/capabilities/asset-import.d.ts +4 -1
- package/dist/core/capabilities/asset-import.js +15 -6
- package/dist/core/capabilities/capture-gameplay.js +1 -1
- package/dist/core/capabilities/connect-signal.d.ts +2 -2
- package/dist/core/capabilities/content-make/index.d.ts +81 -0
- package/dist/core/capabilities/content-make/index.js +706 -0
- package/dist/core/capabilities/content-make/media.d.ts +57 -0
- package/dist/core/capabilities/content-make/media.js +172 -0
- package/dist/core/capabilities/content-make/plan.d.ts +130 -0
- package/dist/core/capabilities/content-make/plan.js +433 -0
- package/dist/core/capabilities/content-make/render.d.ts +64 -0
- package/dist/core/capabilities/content-make/render.js +370 -0
- package/dist/core/capabilities/content-make/study.d.ts +13 -0
- package/dist/core/capabilities/content-make/study.js +165 -0
- package/dist/core/capabilities/content-make/types.d.ts +173 -0
- package/dist/core/capabilities/content-make/types.js +6 -0
- package/dist/core/capabilities/content-make/voice.d.ts +7 -0
- package/dist/core/capabilities/content-make/voice.js +27 -0
- package/dist/core/capabilities/events.d.ts +2 -2
- package/dist/core/capabilities/export-game.d.ts +4 -1
- package/dist/core/capabilities/export-game.js +4 -3
- package/dist/core/capabilities/export-presets.d.ts +2 -1
- package/dist/core/capabilities/export-presets.js +2 -1
- package/dist/core/capabilities/go-live.d.ts +14 -0
- package/dist/core/capabilities/go-live.js +408 -0
- package/dist/core/capabilities/image-generation.d.ts +6 -6
- package/dist/core/capabilities/item-manifest.d.ts +92 -0
- package/dist/core/capabilities/item-manifest.js +247 -0
- package/dist/core/capabilities/placement.d.ts +7 -7
- package/dist/core/capabilities/projects-local.d.ts +68 -0
- package/dist/core/capabilities/projects-local.js +172 -0
- package/dist/core/capabilities/publish-build.d.ts +13 -2
- package/dist/core/capabilities/publish-build.js +87 -21
- package/dist/core/capabilities/publish-download.js +1 -1
- package/dist/core/capabilities/runtime-control.d.ts +7 -2
- package/dist/core/capabilities/runtime-control.js +27 -1
- package/dist/core/capabilities/seeing/args.d.ts +2 -2
- package/dist/core/capabilities/store-checks.d.ts +45 -0
- package/dist/core/capabilities/store-checks.js +85 -0
- package/dist/core/capabilities/store-details.d.ts +37 -0
- package/dist/core/capabilities/store-details.js +94 -0
- package/dist/core/capabilities/summer-bundle.d.ts +21 -2
- package/dist/core/capabilities/summer-bundle.js +54 -7
- package/dist/core/capabilities/sync-items.d.ts +89 -0
- package/dist/core/capabilities/sync-items.js +352 -0
- package/dist/core/capabilities/tool-dispatch.js +66 -6
- package/dist/core/capabilities/video-ogv.d.ts +49 -0
- package/dist/core/capabilities/video-ogv.js +260 -0
- package/dist/core/capability-skew.js +2 -2
- package/dist/core/cli-usage-beacon.js +26 -2
- package/dist/core/cli-usage.d.ts +5 -2
- package/dist/core/cli-usage.js +8 -2
- package/dist/core/engine-install.d.ts +53 -0
- package/dist/core/engine-install.js +93 -0
- package/dist/core/engine-installer.d.ts +134 -0
- package/dist/core/engine-installer.js +760 -0
- package/dist/core/engine-launch.d.ts +126 -0
- package/dist/core/engine-launch.js +398 -0
- package/dist/core/engine-self-start.d.ts +63 -0
- package/dist/core/engine-self-start.js +282 -0
- package/dist/core/engine.d.ts +7 -1
- package/dist/core/engine.js +9 -2
- package/dist/core/error-codes.d.ts +4 -1
- package/dist/core/error-codes.js +7 -3
- package/dist/core/library-read.d.ts +2 -2
- package/dist/core/library-search.d.ts +2 -2
- package/dist/core/local-project.d.ts +50 -0
- package/dist/core/local-project.js +234 -0
- package/dist/core/ops/areas.js +9 -3
- package/dist/core/ops/catalog.d.ts +9 -0
- package/dist/core/ops/catalog.js +56 -4
- package/dist/core/ops/engine-answer.d.ts +4 -1
- package/dist/core/ops/engine-answer.js +16 -3
- package/dist/core/ops/engine.js +4 -4
- package/dist/core/ops/save-files.d.ts +6 -1
- package/dist/core/ops/save-files.js +67 -50
- package/dist/core/ops/search.js +9 -2
- package/dist/core/preview/input.d.ts +35 -0
- package/dist/core/preview/input.js +105 -0
- package/dist/core/preview/launch-config.d.ts +52 -0
- package/dist/core/preview/launch-config.js +137 -0
- package/dist/core/preview/page.d.ts +22 -0
- package/dist/core/preview/page.js +232 -0
- package/dist/core/preview/preview-source.d.ts +179 -0
- package/dist/core/preview/preview-source.js +350 -0
- package/dist/core/preview/server.d.ts +26 -0
- package/dist/core/preview/server.js +218 -0
- package/dist/core/telemetry.d.ts +20 -16
- package/dist/core/telemetry.js +92 -16
- package/dist/core/templates.d.ts +20 -0
- package/dist/core/templates.js +300 -3
- package/dist/core/tool-profiles.d.ts +2 -2
- package/dist/core/tool-profiles.js +1 -0
- package/dist/core/usage-fields.d.ts +41 -0
- package/dist/core/usage-fields.js +150 -0
- package/dist/core/util/sleep.d.ts +3 -0
- package/dist/core/util/sleep.js +12 -0
- package/dist/installer/agent-config.d.ts +29 -0
- package/dist/installer/agent-config.js +75 -11
- package/dist/installer/agent-detect.d.ts +44 -0
- package/dist/installer/agent-detect.js +166 -0
- package/dist/installer/agent-table.d.ts +19 -0
- package/dist/installer/agent-table.js +130 -0
- package/dist/installer/setup-error.d.ts +19 -0
- package/dist/installer/setup-error.js +34 -0
- package/dist/installer/version-check.d.ts +5 -0
- package/dist/installer/version-check.js +21 -0
- package/dist/mcp/ops-surface.d.ts +7 -0
- package/dist/mcp/ops-surface.js +40 -3
- package/dist/mcp/server.d.ts +14 -4
- package/dist/mcp/server.js +42 -8
- package/dist/mcp/tool-surface.d.ts +8 -0
- package/dist/mcp/tool-surface.js +19 -2
- package/dist/mcp/tools/creator-tools.js +92 -14
- package/dist/mcp/tools/debug-tools.js +2 -0
- package/dist/mcp/tools/local-project-tools.d.ts +6 -0
- package/dist/mcp/tools/local-project-tools.js +33 -0
- package/dist/mcp/tools/navigation-tools.js +2 -1
- package/dist/mcp/tools/project-tools.js +5 -8
- package/dist/mcp/tools/seeing-tools.js +1 -1
- package/dist/mcp/tools/visual-tools.js +1 -1
- package/dist/mcp/tools/with-engine.js +1 -1
- package/dist/project-memory/agent-instructions.js +11 -3
- package/gemini-extension.json +3 -1
- package/library/references/mcp-tools-reference/mcp-tools-reference.md +18 -5
- package/library/skills/README.md +12 -8
- package/library/skills/animated-loop/SKILL.md +10 -16
- package/library/skills/cinematic-cutscene/SKILL.md +7 -18
- package/library/skills/headless-scripting/SKILL.md +7 -4
- package/library/skills/in-game-purchases/SKILL.md +25 -8
- package/library/skills/in-game-purchases/resource.yaml +2 -1
- package/library/skills/launch-ready/SKILL.md +351 -0
- package/library/skills/launch-ready/resource.yaml +45 -0
- package/library/skills/make-content/SKILL.md +202 -0
- package/library/skills/make-content/resource.yaml +32 -0
- package/library/skills/make-game/SKILL.md +6 -4
- package/library/skills/make-game/resource.yaml +2 -1
- package/library/skills/navigate-summer/SKILL.md +4 -2
- package/library/skills/new-project/SKILL.md +15 -22
- package/library/skills/new-project/resource.yaml +2 -1
- package/library/skills/painted-25d-world/SKILL.md +366 -0
- package/library/skills/painted-25d-world/_building-blocks/blender-route.md +33 -0
- package/library/skills/painted-25d-world/_building-blocks/blockout_builder.gd +149 -0
- package/library/skills/painted-25d-world/_building-blocks/capture_paint_view.gd +32 -0
- package/library/skills/painted-25d-world/_building-blocks/paint_projection.gdshader +23 -0
- package/library/skills/painted-25d-world/_building-blocks/paintcam_check.gd +25 -0
- package/library/skills/painted-25d-world/_building-blocks/painted_door.gd +17 -0
- package/library/skills/painted-25d-world/_building-blocks/painted_location.gd +56 -0
- package/library/skills/painted-25d-world/_building-blocks/painted_player.gd +125 -0
- package/library/skills/painted-25d-world/_building-blocks/painted_world.gd +111 -0
- package/library/skills/painted-25d-world/_building-blocks/tools/cam.py +57 -0
- package/library/skills/painted-25d-world/_building-blocks/tools/detail4k.py +81 -0
- package/library/skills/painted-25d-world/_building-blocks/tools/overlay.py +17 -0
- package/library/skills/painted-25d-world/_building-blocks/tools/preview.py +60 -0
- package/library/skills/painted-25d-world/resource.yaml +39 -0
- package/library/skills/publish-your-game/SKILL.md +13 -14
- package/library/skills/publish-your-game/resource.yaml +4 -3
- package/library/skills/release-checklist/SKILL.md +188 -0
- package/library/skills/release-checklist/resource.yaml +40 -0
- package/library/skills/release-readiness/SKILL.md +1 -1
- package/library/skills/release-readiness/resource.yaml +1 -1
- package/library/skills/store-art/SKILL.md +1 -1
- package/library/skills/store-art/resource.yaml +1 -1
- package/library/skills/store-listing/SKILL.md +1 -1
- package/library/skills/store-listing/resource.yaml +1 -1
- package/library/skills/summer-cli/SKILL.md +2 -0
- package/library/skills/summer-store/SKILL.md +295 -39
- package/library/skills/summer-store/resource.yaml +9 -3
- package/library/skills/trailer-shot/SKILL.md +3 -3
- package/library/templates/README.md +5 -0
- package/library/tools/content-make/resource.yaml +132 -0
- package/library/tools/frame-shot/GUIDE.md +1 -1
- package/library/tools/go-live/resource.yaml +67 -0
- package/library/tools/import-from-url/GUIDE.md +1 -0
- package/library/tools/import-from-url-batch/GUIDE.md +1 -1
- package/library/tools/play/GUIDE.md +2 -0
- package/library/tools/projects-create-local/resource.yaml +55 -0
- package/library/tools/projects-open-local/resource.yaml +51 -0
- package/library/tools/publish-build/resource.yaml +3 -3
- package/library/tools/screenshot/GUIDE.md +1 -1
- package/library/tools/store-set-details/resource.yaml +114 -0
- package/library/tools/sync-items/resource.yaml +72 -0
- package/package.json +1 -1
- package/registry/generated/aliases.json +6 -0
- package/registry/generated/counts.json +3 -3
- package/registry/generated/gemini-extension.json +3 -1
- package/registry/generated/index.json +463 -37
- package/registry/generated/marketplace.claude.json +1 -1
- package/registry/generated/plugin.claude-directory.json +4 -2
- package/registry/generated/plugin.claude.json +3 -1
- package/registry/generated/plugin.codex.json +5 -3
- package/registry/generated/plugin.cursor.json +4 -2
- package/registry/generated/plugin.factory.json +4 -2
- package/registry/generated/server.json +2 -2
- package/registry/generated/skills-index.md +12 -8
- package/registry/generated/skills-registry.json +62 -5
- package/registry/operations/legacy-names.json +40 -0
- package/registry/operations/public-catalog.json +10144 -387
|
@@ -3,9 +3,21 @@
|
|
|
3
3
|
* summer-bundle.json, client.pck, and for a game with a host server.pck plus
|
|
4
4
|
* config/. This reads what the build upload declares; the Platform verifies
|
|
5
5
|
* the uploaded bytes again. Same rules as Studio's browser check.
|
|
6
|
+
*
|
|
7
|
+
* Two layouts, chosen by the manifest's schema (summer-platform
|
|
8
|
+
* docs/build-publications.md, "summer.games bundle layout"):
|
|
9
|
+
* - summer.bundle.v2 (Summer Engine 0.7.4 and newer) is fixed: exactly the
|
|
10
|
+
* paths in BUNDLE_V2_PATHS and nothing else, whatever the game declares.
|
|
11
|
+
* - summer.bundle.v1 (older engines): a hosted game's configuration may be
|
|
12
|
+
* any number of config/ files.
|
|
6
13
|
*/
|
|
7
|
-
export declare const
|
|
14
|
+
export declare const BUNDLE_SCHEMA_V1 = "summer.bundle.v1";
|
|
15
|
+
export declare const BUNDLE_SCHEMA_V2 = "summer.bundle.v2";
|
|
16
|
+
export declare const BUNDLE_SCHEMAS: readonly ["summer.bundle.v1", "summer.bundle.v2"];
|
|
17
|
+
export type BundleSchema = (typeof BUNDLE_SCHEMAS)[number];
|
|
8
18
|
export declare const BUNDLE_MANIFEST = "summer-bundle.json";
|
|
19
|
+
/** Every path a summer.bundle.v2 export may hold; the store refuses any other entry by name. */
|
|
20
|
+
export declare const BUNDLE_V2_PATHS: readonly ["summer-bundle.json", "client.pck", "server.pck", "config/summer.build.json", "config/world-definitions.json", "config/world-support.json"];
|
|
9
21
|
/** The same platforms, in any order. */
|
|
10
22
|
export declare function samePlatforms(a: readonly string[], b: readonly string[]): boolean;
|
|
11
23
|
/**
|
|
@@ -23,13 +35,20 @@ export declare class BuildToolError extends Error {
|
|
|
23
35
|
readonly detail?: Record<string, unknown> | undefined;
|
|
24
36
|
constructor(code: string, message: string, recovery: string, status?: number | undefined, detail?: Record<string, unknown> | undefined);
|
|
25
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The structured failure every face prints for a BuildToolError: its code,
|
|
40
|
+
* the message without the recovery, the recovery, the HTTP status when the
|
|
41
|
+
* store sent one, and its detail (a manifest's problem list, the person's
|
|
42
|
+
* games).
|
|
43
|
+
*/
|
|
44
|
+
export declare function buildToolFailure(error: BuildToolError): Record<string, unknown>;
|
|
26
45
|
export interface BundleFile {
|
|
27
46
|
path: string;
|
|
28
47
|
sha256: string;
|
|
29
48
|
size: number;
|
|
30
49
|
}
|
|
31
50
|
export interface SummerBundle {
|
|
32
|
-
schema:
|
|
51
|
+
schema: BundleSchema;
|
|
33
52
|
summerVersion: string | null;
|
|
34
53
|
engineSha: string | null;
|
|
35
54
|
/** res:// scene the client pack opens. */
|
|
@@ -6,12 +6,30 @@ import { readZipEntries, readZipEntry, ZipReadError } from "../util/zip.js";
|
|
|
6
6
|
* summer-bundle.json, client.pck, and for a game with a host server.pck plus
|
|
7
7
|
* config/. This reads what the build upload declares; the Platform verifies
|
|
8
8
|
* the uploaded bytes again. Same rules as Studio's browser check.
|
|
9
|
+
*
|
|
10
|
+
* Two layouts, chosen by the manifest's schema (summer-platform
|
|
11
|
+
* docs/build-publications.md, "summer.games bundle layout"):
|
|
12
|
+
* - summer.bundle.v2 (Summer Engine 0.7.4 and newer) is fixed: exactly the
|
|
13
|
+
* paths in BUNDLE_V2_PATHS and nothing else, whatever the game declares.
|
|
14
|
+
* - summer.bundle.v1 (older engines): a hosted game's configuration may be
|
|
15
|
+
* any number of config/ files.
|
|
9
16
|
*/
|
|
10
|
-
export const
|
|
17
|
+
export const BUNDLE_SCHEMA_V1 = "summer.bundle.v1";
|
|
18
|
+
export const BUNDLE_SCHEMA_V2 = "summer.bundle.v2";
|
|
19
|
+
export const BUNDLE_SCHEMAS = [BUNDLE_SCHEMA_V1, BUNDLE_SCHEMA_V2];
|
|
11
20
|
export const BUNDLE_MANIFEST = "summer-bundle.json";
|
|
12
21
|
const BUNDLE_CLIENT = "client.pck";
|
|
13
22
|
const BUNDLE_SERVER = "server.pck";
|
|
14
23
|
const BUNDLE_BUILD = "config/summer.build.json";
|
|
24
|
+
/** Every path a summer.bundle.v2 export may hold; the store refuses any other entry by name. */
|
|
25
|
+
export const BUNDLE_V2_PATHS = [
|
|
26
|
+
BUNDLE_MANIFEST,
|
|
27
|
+
BUNDLE_CLIENT,
|
|
28
|
+
BUNDLE_SERVER,
|
|
29
|
+
BUNDLE_BUILD,
|
|
30
|
+
"config/world-definitions.json",
|
|
31
|
+
"config/world-support.json",
|
|
32
|
+
];
|
|
15
33
|
/** The same platforms, in any order. */
|
|
16
34
|
export function samePlatforms(a, b) {
|
|
17
35
|
return a.length === b.length && [...a].sort().join(",") === [...b].sort().join(",");
|
|
@@ -48,6 +66,22 @@ export class BuildToolError extends Error {
|
|
|
48
66
|
this.name = "BuildToolError";
|
|
49
67
|
}
|
|
50
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The structured failure every face prints for a BuildToolError: its code,
|
|
71
|
+
* the message without the recovery, the recovery, the HTTP status when the
|
|
72
|
+
* store sent one, and its detail (a manifest's problem list, the person's
|
|
73
|
+
* games).
|
|
74
|
+
*/
|
|
75
|
+
export function buildToolFailure(error) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
code: error.code,
|
|
79
|
+
message: error.message.replace(` ${error.recovery}`, ""),
|
|
80
|
+
recovery: error.recovery,
|
|
81
|
+
...(error.status ? { status: error.status } : {}),
|
|
82
|
+
...error.detail,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
51
85
|
function bundleError(message) {
|
|
52
86
|
return new BuildToolError("bundle_invalid", message, AS_EXPORTED);
|
|
53
87
|
}
|
|
@@ -84,13 +118,18 @@ export async function readSummerBundle(path) {
|
|
|
84
118
|
}
|
|
85
119
|
const client = manifest?.client;
|
|
86
120
|
const mainScene = typeof client?.mainScene === "string" ? client.mainScene : null;
|
|
87
|
-
|
|
121
|
+
const schema = manifest?.schema;
|
|
122
|
+
if (!BUNDLE_SCHEMAS.includes(schema) ||
|
|
88
123
|
client?.path !== BUNDLE_CLIENT ||
|
|
89
124
|
!mainScene?.startsWith("res://") ||
|
|
90
125
|
!Array.isArray(client?.targetPlatforms) ||
|
|
91
126
|
!client.targetPlatforms.every((value) => typeof value === "string") ||
|
|
92
127
|
!Array.isArray(manifest.files)) {
|
|
93
|
-
throw new BuildToolError("bundle_invalid",
|
|
128
|
+
throw new BuildToolError("bundle_invalid", typeof manifest?.schema === "string" && /^summer\.bundle\.v\d+$/.test(manifest.schema) && !BUNDLE_SCHEMAS.includes(schema)
|
|
129
|
+
? `${BUNDLE_MANIFEST} is a ${manifest.schema} manifest, newer than this summer-engine package reads (${BUNDLE_SCHEMAS.join(", ")}).`
|
|
130
|
+
: `${BUNDLE_MANIFEST} is not a ${BUNDLE_SCHEMAS.join(" or ")} manifest.`, typeof manifest?.schema === "string" && /^summer\.bundle\.v\d+$/.test(manifest.schema) && !BUNDLE_SCHEMAS.includes(schema)
|
|
131
|
+
? "Recovery: update the summer-engine package (npx -y summer-engine@latest), then upload the same export."
|
|
132
|
+
: "Recovery: update Summer Engine and export again.");
|
|
94
133
|
}
|
|
95
134
|
const hosted = Boolean(manifest.server);
|
|
96
135
|
const compositionPath = typeof manifest.compositionPath === "string" ? manifest.compositionPath : null;
|
|
@@ -107,10 +146,18 @@ export async function readSummerBundle(path) {
|
|
|
107
146
|
const allowed = (name) => name === BUNDLE_MANIFEST ||
|
|
108
147
|
name === BUNDLE_CLIENT ||
|
|
109
148
|
name === BUNDLE_BUILD ||
|
|
110
|
-
(hosted &&
|
|
149
|
+
(hosted &&
|
|
150
|
+
(name === BUNDLE_SERVER ||
|
|
151
|
+
(schema === BUNDLE_SCHEMA_V2 ? BUNDLE_V2_PATHS.includes(name) : name.startsWith("config/"))));
|
|
111
152
|
const extra = entries.find((entry) => !allowed(entry.name));
|
|
112
|
-
if (extra)
|
|
113
|
-
throw bundleError(
|
|
153
|
+
if (extra) {
|
|
154
|
+
throw bundleError(schema === BUNDLE_SCHEMA_V2
|
|
155
|
+
? `The export contains ${extra.name}, which is not part of the ${BUNDLE_SCHEMA_V2} layout (${BUNDLE_V2_PATHS.join(", ")}).`
|
|
156
|
+
: `The export contains ${extra.name}, which a summer.games export never has.`);
|
|
157
|
+
}
|
|
158
|
+
if (schema === BUNDLE_SCHEMA_V2 && !byName.has(BUNDLE_BUILD)) {
|
|
159
|
+
throw bundleError(`A ${BUNDLE_SCHEMA_V2} export always holds ${BUNDLE_BUILD}, and this one does not.`);
|
|
160
|
+
}
|
|
114
161
|
const files = [];
|
|
115
162
|
for (const item of manifest.files) {
|
|
116
163
|
const entry = typeof item?.path === "string" ? byName.get(item.path) : undefined;
|
|
@@ -150,7 +197,7 @@ export async function readSummerBundle(path) {
|
|
|
150
197
|
throw bundleError("The export must name at least one platform, each once.");
|
|
151
198
|
}
|
|
152
199
|
return {
|
|
153
|
-
schema
|
|
200
|
+
schema,
|
|
154
201
|
summerVersion: typeof manifest.export?.summerVersion === "string" ? manifest.export.summerVersion : null,
|
|
155
202
|
engineSha: typeof manifest.export?.engineSha === "string" ? manifest.export.engineSha : null,
|
|
156
203
|
mainScene: mainScene,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type ManifestItem } from "./item-manifest.js";
|
|
2
|
+
import { type PublishBuildDependencies } from "./publish-build.js";
|
|
3
|
+
/** The most items one owner approval may carry (economy.propose_items). */
|
|
4
|
+
export declare const MAX_ITEMS_PER_PROPOSAL = 50;
|
|
5
|
+
export interface SyncItemsInput {
|
|
6
|
+
gameId?: string;
|
|
7
|
+
project?: string;
|
|
8
|
+
/** The manifest, relative to the project. Defaults to summer.items.json. */
|
|
9
|
+
file?: string;
|
|
10
|
+
/** Only these slugs; the rest of the manifest is left out of the plan. */
|
|
11
|
+
slugs?: string[];
|
|
12
|
+
confirm?: boolean;
|
|
13
|
+
face: "cli" | "mcp";
|
|
14
|
+
}
|
|
15
|
+
/** What the owner is asked to apply: the manifest entry plus the media it now names. */
|
|
16
|
+
export interface ProposedItem {
|
|
17
|
+
slug: string;
|
|
18
|
+
action: "create" | "update";
|
|
19
|
+
definitionId?: string;
|
|
20
|
+
/** The revision the plan was made against; the owner's apply refuses if it moved on. */
|
|
21
|
+
basedOnRevisionId?: string;
|
|
22
|
+
item: Omit<ManifestItem, "media"> & {
|
|
23
|
+
mediaAssetIds: string[];
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface ProposeItemsRequest {
|
|
27
|
+
gameId: string;
|
|
28
|
+
items: ProposedItem[];
|
|
29
|
+
idempotencyKey: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SyncItemsDependencies extends PublishBuildDependencies {
|
|
32
|
+
readFile: (path: string) => Promise<Buffer>;
|
|
33
|
+
/** Ask the owner to apply the plan (the hosted economy.propose_items). */
|
|
34
|
+
propose: (request: ProposeItemsRequest) => Promise<Record<string, unknown>>;
|
|
35
|
+
/** The hosted MCP this machine's store sign-in was made for, or null without one. */
|
|
36
|
+
signedInResource: () => Promise<string | null>;
|
|
37
|
+
}
|
|
38
|
+
export declare const syncItemsDefaults: SyncItemsDependencies;
|
|
39
|
+
type Json = Record<string, any>;
|
|
40
|
+
export interface LocalMedia {
|
|
41
|
+
path: string;
|
|
42
|
+
sha256: string;
|
|
43
|
+
sizeBytes: number;
|
|
44
|
+
kind: "IMAGE" | "MODEL_3D";
|
|
45
|
+
contentType: string;
|
|
46
|
+
}
|
|
47
|
+
export interface RemoteItem {
|
|
48
|
+
definitionId: string;
|
|
49
|
+
/** Who manages the item: CREATOR_CATALOG (the creator's store items) or PASSPORT_CONTROL (a Passport collectible). */
|
|
50
|
+
authority?: string | null;
|
|
51
|
+
state: string | null;
|
|
52
|
+
slug: string;
|
|
53
|
+
revision: Json;
|
|
54
|
+
priceSparks: number | null;
|
|
55
|
+
mediaSha256: string[];
|
|
56
|
+
}
|
|
57
|
+
export type ItemPlan = {
|
|
58
|
+
slug: string;
|
|
59
|
+
action: "create";
|
|
60
|
+
changes: string[];
|
|
61
|
+
} | {
|
|
62
|
+
slug: string;
|
|
63
|
+
action: "update";
|
|
64
|
+
definitionId: string;
|
|
65
|
+
revisionId: string;
|
|
66
|
+
revision: "edit_draft" | "new_draft";
|
|
67
|
+
changes: string[];
|
|
68
|
+
warnings?: string[];
|
|
69
|
+
} | {
|
|
70
|
+
slug: string;
|
|
71
|
+
action: "unchanged";
|
|
72
|
+
definitionId: string;
|
|
73
|
+
} | {
|
|
74
|
+
slug: string;
|
|
75
|
+
action: "blocked";
|
|
76
|
+
definitionId?: string;
|
|
77
|
+
reason: string;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The plan for one manifest item against what the Game has under its slug.
|
|
81
|
+
* Pure: the same inputs always give the same plan.
|
|
82
|
+
*/
|
|
83
|
+
export declare function planItem(item: ManifestItem, media: LocalMedia[], remote: RemoteItem | null): ItemPlan;
|
|
84
|
+
export type SyncItemsResult = Record<string, unknown> & {
|
|
85
|
+
ok: true;
|
|
86
|
+
status: string;
|
|
87
|
+
};
|
|
88
|
+
export declare function syncItems(input: SyncItemsInput, overrides?: Partial<SyncItemsDependencies>): Promise<SyncItemsResult>;
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
|
+
import { resolveGatewayUrl } from "../config.js";
|
|
5
|
+
import { readStoredOAuthToken, resolveMcpResourceUrl } from "../oauth.js";
|
|
6
|
+
import { appendStoreJsonLine } from "../store.js";
|
|
7
|
+
import { resolveExportProject } from "./export-game.js";
|
|
8
|
+
import { behaviorOf, MEDIA_TYPES, readItemsManifest } from "./item-manifest.js";
|
|
9
|
+
import { createStore, defaultDependencies as publishDefaults, listGamesOrThrow, storeToken } from "./publish-build.js";
|
|
10
|
+
import { BuildToolError } from "./summer-bundle.js";
|
|
11
|
+
/**
|
|
12
|
+
* summer_sync_items: bring a Game's items in line with summer.items.json,
|
|
13
|
+
* matched by creator slug, so the same file gives the staging Game and the
|
|
14
|
+
* production Game the same items (each Game keeps its own definition ids).
|
|
15
|
+
*
|
|
16
|
+
* Through the creator store proxy Studio uses (PSE /api/creator-store/* ->
|
|
17
|
+
* /v1/management/*), with the store sign-in from "summer login --store":
|
|
18
|
+
*
|
|
19
|
+
* GET games/{g}/item-definitions?slug= the item with this slug, or none
|
|
20
|
+
* GET games/{g}/item-definitions/{d} its latest revision (presentation, behavior, media)
|
|
21
|
+
* GET .../revisions/{r}/price-book/diff its Sparks price
|
|
22
|
+
* GET media-assets/{a} the sha256 of each image it shows
|
|
23
|
+
* POST media-assets:begin-upload, PUT, :complete only the images that changed (confirm=true)
|
|
24
|
+
*
|
|
25
|
+
* That sign-in is a delegated agent credential: the platform lets it read and
|
|
26
|
+
* upload media, and refuses every item write with owner_approval_required
|
|
27
|
+
* (agent_gate.go). So this tool never writes an item. With confirm=true it
|
|
28
|
+
* uploads the changed images and asks the owner to apply the plan (the hosted
|
|
29
|
+
* economy.propose_items operation): the owner approves in Studio, and Studio
|
|
30
|
+
* creates or revises each item by slug, sets its price and submits it for
|
|
31
|
+
* review with the owner's own session. Nothing here activates or publishes an
|
|
32
|
+
* item; players see it only after Summer's review and the owner's release.
|
|
33
|
+
*
|
|
34
|
+
* Runs against the gateway the CLI is set to (SUMMER_GATEWAY_URL or
|
|
35
|
+
* "summer config set gateway.url"), so staging and production differ only in
|
|
36
|
+
* that setting, the hosted MCP (SUMMER_MCP_RESOURCE_URL), the store sign-in
|
|
37
|
+
* and the gameId. The machine keeps one store sign-in: after switching
|
|
38
|
+
* between staging and production, "summer login --store --force" signs in to
|
|
39
|
+
* the other one. A sign-in for another hosted MCP stops the call up front.
|
|
40
|
+
*/
|
|
41
|
+
const seg = encodeURIComponent;
|
|
42
|
+
const MAX_PAGES = 20;
|
|
43
|
+
/** The most items one owner approval may carry (economy.propose_items). */
|
|
44
|
+
export const MAX_ITEMS_PER_PROPOSAL = 50;
|
|
45
|
+
/** Revisions a change may still be written into; the others get a new draft first. */
|
|
46
|
+
const EDITABLE = new Set(["DRAFT", "CHANGES_REQUESTED"]);
|
|
47
|
+
/** Revisions Summer is reviewing: a change waits until the review is decided. */
|
|
48
|
+
const IN_REVIEW = new Set(["SUBMITTED", "VERIFYING"]);
|
|
49
|
+
/** Why economy.propose_items did not take the plan, in plain words, and what to do. */
|
|
50
|
+
const PROPOSAL_FAILURES = {
|
|
51
|
+
invalid_input: "Recovery: Summer refused a field of the plan (details name it). Fix summer.items.json and call again; the uploaded media is reused.",
|
|
52
|
+
idempotency_conflict: "Recovery: this plan's key was already used for a different plan. Call again with confirm=false to read the Game afresh, then confirm the new plan.",
|
|
53
|
+
unavailable: "Recovery: Summer could not take the plan right now. Call again in a few minutes with the same summer.items.json; the owner is asked once.",
|
|
54
|
+
not_found: "Recovery: Summer found no such game for this account on this gateway. Check gameId with games.list (staging and production games have different ids).",
|
|
55
|
+
};
|
|
56
|
+
async function proposeOnHosted(request) {
|
|
57
|
+
// Loaded on use: the operation catalog imports the capabilities this file sits next to.
|
|
58
|
+
const { answerOfHosted, callHostedV4 } = await import("../ops/cloud.js");
|
|
59
|
+
const { idempotencyKey, ...input } = request;
|
|
60
|
+
return answerOfHosted(await callHostedV4("summer_call", { op: "economy.propose_items", input: { ...input, idempotencyKey } }));
|
|
61
|
+
}
|
|
62
|
+
export const syncItemsDefaults = {
|
|
63
|
+
...publishDefaults,
|
|
64
|
+
readFile: (path) => readFile(path),
|
|
65
|
+
propose: proposeOnHosted,
|
|
66
|
+
signedInResource: async () => (await readStoredOAuthToken())?.resource ?? null,
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* The one store sign-in is for one hosted MCP. When SUMMER_MCP_RESOURCE_URL
|
|
70
|
+
* now names another (staging after production, or back), the store refuses
|
|
71
|
+
* the sign-in or the plan would reach the other environment: stop before
|
|
72
|
+
* anything is read or sent.
|
|
73
|
+
*/
|
|
74
|
+
async function requireSignInForThisEnvironment(deps) {
|
|
75
|
+
const signedIn = await deps.signedInResource();
|
|
76
|
+
const current = resolveMcpResourceUrl();
|
|
77
|
+
if (signedIn && signedIn !== current) {
|
|
78
|
+
throw new BuildToolError("store_login_other_environment", `This machine's store sign-in is for ${signedIn}, but SUMMER_MCP_RESOURCE_URL is ${current}. Nothing was read or sent.`, 'Recovery: switching between staging and production takes both SUMMER_GATEWAY_URL and SUMMER_MCP_RESOURCE_URL, then "summer login --store --force" to sign in to that environment; call again after it.', undefined, { signedInResource: signedIn, resource: current });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const sameList = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
|
|
82
|
+
/**
|
|
83
|
+
* The plan for one manifest item against what the Game has under its slug.
|
|
84
|
+
* Pure: the same inputs always give the same plan.
|
|
85
|
+
*/
|
|
86
|
+
export function planItem(item, media, remote) {
|
|
87
|
+
const wanted = behaviorOf(item);
|
|
88
|
+
if (!remote) {
|
|
89
|
+
// The platform refuses an item created without media.
|
|
90
|
+
if (!media.length)
|
|
91
|
+
return { slug: item.slug, action: "blocked", reason: "a new item needs at least one image" };
|
|
92
|
+
return { slug: item.slug, action: "create", changes: ["new item", ...(item.priceSparks !== undefined ? ["price"] : []), "media"] };
|
|
93
|
+
}
|
|
94
|
+
// A Passport collectible has its own lifecycle (Passport control, take-down), and the owner's apply only writes store items.
|
|
95
|
+
if (remote.authority === "PASSPORT_CONTROL") {
|
|
96
|
+
return {
|
|
97
|
+
slug: item.slug,
|
|
98
|
+
action: "blocked",
|
|
99
|
+
definitionId: remote.definitionId,
|
|
100
|
+
reason: `The Game's "${item.slug}" is a Passport collectible, which summer.items.json does not manage. Give this item another slug, or change the Passport item in Studio.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const revision = remote.revision;
|
|
104
|
+
if (typeof revision.revisionId !== "string" || !revision.revisionId) {
|
|
105
|
+
return { slug: item.slug, action: "blocked", definitionId: remote.definitionId, reason: `Summer did not say which revision "${item.slug}" is on. Call again in a minute.` };
|
|
106
|
+
}
|
|
107
|
+
const behavior = revision.behavior ?? {};
|
|
108
|
+
// Class and ownership mode belong to the definition: a revision cannot change them.
|
|
109
|
+
if (behavior.class !== wanted.class || behavior.ownershipMode !== wanted.ownershipMode) {
|
|
110
|
+
return {
|
|
111
|
+
slug: item.slug,
|
|
112
|
+
action: "blocked",
|
|
113
|
+
definitionId: remote.definitionId,
|
|
114
|
+
reason: `The Game's "${item.slug}" is ${behavior.class} ${behavior.ownershipMode}; summer.items.json says ${wanted.class} ${wanted.ownershipMode}. Class and ownership mode cannot change: keep them, or give the new item a new slug.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const presentation = revision.presentation ?? {};
|
|
118
|
+
const changes = [];
|
|
119
|
+
if (presentation.name !== item.name)
|
|
120
|
+
changes.push("name");
|
|
121
|
+
if (presentation.description !== item.description)
|
|
122
|
+
changes.push("description");
|
|
123
|
+
if (!sameList(presentation.tags ?? [], item.tags))
|
|
124
|
+
changes.push("tags");
|
|
125
|
+
if ((presentation.backgroundColor ?? undefined) !== item.backgroundColor)
|
|
126
|
+
changes.push("backgroundColor");
|
|
127
|
+
const rights = behavior.rights ?? {};
|
|
128
|
+
const ownershipChanged = (behavior.maxOwned ?? undefined) !== wanted.maxOwned ||
|
|
129
|
+
rights.policy !== wanted.rights.policy ||
|
|
130
|
+
rights.consumable !== wanted.rights.consumable ||
|
|
131
|
+
rights.directTransferAllowed !== wanted.rights.directTransferAllowed ||
|
|
132
|
+
rights.giftingAfterOwnershipAllowed !== wanted.rights.giftingAfterOwnershipAllowed ||
|
|
133
|
+
rights.publicResalePolicyEligible !== wanted.rights.publicResalePolicyEligible ||
|
|
134
|
+
JSON.stringify(behavior.cosmetic ?? null) !== JSON.stringify(wanted.cosmetic ?? null);
|
|
135
|
+
if (ownershipChanged)
|
|
136
|
+
changes.push("ownership rules");
|
|
137
|
+
if ((revision.supply ?? undefined) !== item.supply)
|
|
138
|
+
changes.push("supply");
|
|
139
|
+
if (!sameList(remote.mediaSha256, media.map((file) => file.sha256)))
|
|
140
|
+
changes.push("media");
|
|
141
|
+
// No priceSparks leaves the item's price as it is (a price book cannot be removed).
|
|
142
|
+
if (item.priceSparks !== undefined && remote.priceSparks !== item.priceSparks)
|
|
143
|
+
changes.push("price");
|
|
144
|
+
if (!changes.length)
|
|
145
|
+
return { slug: item.slug, action: "unchanged", definitionId: remote.definitionId };
|
|
146
|
+
const state = String(revision.state ?? "");
|
|
147
|
+
if (IN_REVIEW.has(state)) {
|
|
148
|
+
return { slug: item.slug, action: "blocked", definitionId: remote.definitionId, reason: `Summer is reviewing "${item.slug}" (${state}); its changes (${changes.join(", ")}) wait until the review is decided. Call again then.` };
|
|
149
|
+
}
|
|
150
|
+
const live = remote.state === "LIVE";
|
|
151
|
+
return {
|
|
152
|
+
slug: item.slug,
|
|
153
|
+
action: "update",
|
|
154
|
+
definitionId: remote.definitionId,
|
|
155
|
+
revisionId: String(revision.revisionId),
|
|
156
|
+
revision: EDITABLE.has(state) ? "edit_draft" : "new_draft",
|
|
157
|
+
changes,
|
|
158
|
+
...(live && ownershipChanged ? { warnings: [`"${item.slug}" is live: new ownership rules apply to copies players get after the new revision is released.`] } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async function hashMedia(project, path, deps) {
|
|
162
|
+
const type = MEDIA_TYPES[extname(path).toLowerCase()];
|
|
163
|
+
let bytes;
|
|
164
|
+
try {
|
|
165
|
+
bytes = await deps.readFile(join(project, path));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
throw new BuildToolError("item_media_missing", `${path} (named in summer.items.json) is not in the project.`, "Recovery: add the file or fix its path, then call again.");
|
|
169
|
+
}
|
|
170
|
+
if (bytes.length === 0 || bytes.length > type.maxBytes) {
|
|
171
|
+
throw new BuildToolError("item_media_too_large", `${path} is ${bytes.length} bytes; the store takes 1 to ${type.maxBytes} bytes for this kind.`, "Recovery: export a smaller file and call again.");
|
|
172
|
+
}
|
|
173
|
+
return { path, sha256: createHash("sha256").update(bytes).digest("hex"), sizeBytes: bytes.length, kind: type.kind, contentType: type.contentType };
|
|
174
|
+
}
|
|
175
|
+
/** The Game's item with this slug (0 or 1), its latest revision, price and media digests. */
|
|
176
|
+
async function readRemote(store, gameId, slug) {
|
|
177
|
+
const page = await store.call("GET", `games/${seg(gameId)}/item-definitions?slug=${seg(slug)}`);
|
|
178
|
+
const found = (page.items ?? []).find((definition) => definition.slug === slug);
|
|
179
|
+
if (!found)
|
|
180
|
+
return null;
|
|
181
|
+
const definitionId = String(found.definitionId);
|
|
182
|
+
// A Passport collectible is blocked by its authority alone (planItem): nothing else of it is read.
|
|
183
|
+
if (found.authority === "PASSPORT_CONTROL") {
|
|
184
|
+
return { definitionId, authority: "PASSPORT_CONTROL", state: typeof found.state === "string" ? found.state : null, slug, revision: {}, priceSparks: null, mediaSha256: [] };
|
|
185
|
+
}
|
|
186
|
+
const current = await store.call("GET", `games/${seg(gameId)}/item-definitions/${seg(definitionId)}`);
|
|
187
|
+
const revision = current.revision ?? {};
|
|
188
|
+
let priceSparks = null;
|
|
189
|
+
if (revision.priceBookId) {
|
|
190
|
+
const diff = await store.call("GET", `games/${seg(gameId)}/item-definitions/${seg(definitionId)}/revisions/${seg(String(revision.revisionId))}/price-book/diff`);
|
|
191
|
+
priceSparks = typeof diff.priceBook?.baseSparksAmount === "number" ? diff.priceBook.baseSparksAmount : null;
|
|
192
|
+
}
|
|
193
|
+
const mediaSha256 = [];
|
|
194
|
+
for (const assetId of (revision.mediaAssetIds ?? [])) {
|
|
195
|
+
const asset = await store.call("GET", `media-assets/${seg(assetId)}`);
|
|
196
|
+
mediaSha256.push(String(asset.sha256 ?? ""));
|
|
197
|
+
}
|
|
198
|
+
const authority = typeof current.definition?.authority === "string" ? current.definition.authority : typeof found.authority === "string" ? found.authority : null;
|
|
199
|
+
return { definitionId, authority, state: typeof current.definition?.state === "string" ? current.definition.state : null, slug, revision, priceSparks, mediaSha256 };
|
|
200
|
+
}
|
|
201
|
+
/** Upload one file as a Game media asset; the same file and name continue the same upload. */
|
|
202
|
+
async function uploadMedia(store, gameId, file, project, deps) {
|
|
203
|
+
const originalFilename = basename(file.path);
|
|
204
|
+
const key = (step) => createHash("sha256").update(`${gameId}:${file.sha256}:${originalFilename}:item-media:${step}`).digest("hex");
|
|
205
|
+
const contract = await store.call("POST", "media-assets:begin-upload", {
|
|
206
|
+
key: key("begin"),
|
|
207
|
+
body: { gameId, kind: file.kind, contentType: file.contentType, byteLength: file.sizeBytes, sha256: file.sha256, originalFilename },
|
|
208
|
+
});
|
|
209
|
+
const assetId = String(contract.assetId ?? "");
|
|
210
|
+
if (!assetId || !contract.uploadSessionId)
|
|
211
|
+
throw new BuildToolError("store_invalid_response", "The store did not return a media upload.", "Recovery: retry; the same upload continues.");
|
|
212
|
+
const asset = await store.call("GET", `media-assets/${seg(assetId)}`);
|
|
213
|
+
if (asset.state === "PENDING_UPLOAD") {
|
|
214
|
+
if (Array.isArray(contract.parts) && contract.parts.length) {
|
|
215
|
+
throw new BuildToolError("item_media_too_large", `${file.path} needs a multipart upload, which this tool does not do.`, "Recovery: use a smaller file, or upload it in Studio.");
|
|
216
|
+
}
|
|
217
|
+
const response = await deps.fetch(String(contract.url), {
|
|
218
|
+
method: String(contract.method ?? "PUT"),
|
|
219
|
+
headers: contract.headers ?? {},
|
|
220
|
+
body: new Uint8Array(await deps.readFile(join(project, file.path))),
|
|
221
|
+
redirect: "error",
|
|
222
|
+
signal: AbortSignal.timeout(300_000),
|
|
223
|
+
});
|
|
224
|
+
if (!response.ok && response.status !== 412) {
|
|
225
|
+
throw new BuildToolError("item_media_upload_failed", `${file.path} did not upload (${response.status}).`, "Recovery: call again; the same upload continues.");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (asset.state === "PENDING_UPLOAD" || asset.state === "UPLOADED") {
|
|
229
|
+
await store.call("POST", `media-assets/${seg(assetId)}:complete`, { key: key("complete"), body: { uploadSessionId: contract.uploadSessionId } });
|
|
230
|
+
}
|
|
231
|
+
if (asset.state === "REJECTED" || asset.state === "EXPIRED") {
|
|
232
|
+
throw new BuildToolError("item_media_refused", `Summer did not keep ${file.path} (${asset.state}).`, "Recovery: check the image (not blank, a real PNG, JPEG or WebP) and call again.");
|
|
233
|
+
}
|
|
234
|
+
return assetId;
|
|
235
|
+
}
|
|
236
|
+
export async function syncItems(input, overrides = {}) {
|
|
237
|
+
const deps = { ...syncItemsDefaults, ...overrides };
|
|
238
|
+
const project = resolveExportProject(input.project);
|
|
239
|
+
const manifest = await readItemsManifest(project, input.file);
|
|
240
|
+
const only = input.slugs?.length ? new Set(input.slugs) : null;
|
|
241
|
+
const unknown = only ? [...only].filter((slug) => !manifest.items.some((item) => item.slug === slug)) : [];
|
|
242
|
+
if (unknown.length) {
|
|
243
|
+
throw new BuildToolError("item_slug_unknown", `summer.items.json has no item ${unknown.map((slug) => `"${slug}"`).join(", ")}.`, "Recovery: pass slugs from summer.items.json, or add the items there first.");
|
|
244
|
+
}
|
|
245
|
+
const items = manifest.items.filter((item) => !only || only.has(item.slug));
|
|
246
|
+
const gameId = input.gameId?.trim();
|
|
247
|
+
if (!gameId)
|
|
248
|
+
return listGamesOrThrow(deps);
|
|
249
|
+
await requireSignInForThisEnvironment(deps);
|
|
250
|
+
const gateway = await resolveGatewayUrl();
|
|
251
|
+
const store = createStore(gateway, await storeToken(deps), deps);
|
|
252
|
+
const media = new Map();
|
|
253
|
+
const remote = new Map();
|
|
254
|
+
const plans = [];
|
|
255
|
+
for (const item of items) {
|
|
256
|
+
const files = await Promise.all(item.media.map((path) => hashMedia(project, path, deps)));
|
|
257
|
+
media.set(item.slug, files);
|
|
258
|
+
const found = await readRemote(store, gameId, item.slug);
|
|
259
|
+
remote.set(item.slug, found);
|
|
260
|
+
plans.push(planItem(item, files, found));
|
|
261
|
+
}
|
|
262
|
+
const notInManifest = await listUnmanaged(store, gameId, new Set(manifest.items.map((item) => item.slug)));
|
|
263
|
+
// Sorted by slug: the same file always gives the same proposal and the same batches.
|
|
264
|
+
const pending = plans.filter((plan) => plan.action === "create" || plan.action === "update").sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
|
|
265
|
+
const blocked = plans.filter((plan) => plan.action === "blocked");
|
|
266
|
+
const batches = Array.from({ length: Math.ceil(pending.length / MAX_ITEMS_PER_PROPOSAL) }, (_, index) => pending.slice(index * MAX_ITEMS_PER_PROPOSAL, (index + 1) * MAX_ITEMS_PER_PROPOSAL).map((plan) => plan.slug));
|
|
267
|
+
const warnings = [
|
|
268
|
+
...plans.flatMap((plan) => (plan.action === "update" && plan.warnings) || []),
|
|
269
|
+
...(batches.length > 1 ? [`${pending.length} items change, and one owner approval takes at most ${MAX_ITEMS_PER_PROPOSAL}: confirm them in ${batches.length} calls, each with slugs from one of batches.`] : []),
|
|
270
|
+
];
|
|
271
|
+
const summary = {
|
|
272
|
+
gateway,
|
|
273
|
+
gameId,
|
|
274
|
+
manifest: manifest.path,
|
|
275
|
+
plan: plans,
|
|
276
|
+
...(notInManifest.length ? { notInManifest } : {}),
|
|
277
|
+
...(warnings.length ? { warnings } : {}),
|
|
278
|
+
...(batches.length > 1 ? { batches } : {}),
|
|
279
|
+
};
|
|
280
|
+
if (input.confirm !== true || !pending.length) {
|
|
281
|
+
return {
|
|
282
|
+
ok: true,
|
|
283
|
+
status: pending.length ? "confirmation_required" : "up_to_date",
|
|
284
|
+
...summary,
|
|
285
|
+
next: pending.length
|
|
286
|
+
? `Show the user this plan for ${gateway} (game ${gameId}): ${pending.length} item(s) to create or change${blocked.length ? `, ${blocked.length} blocked` : ""}. Only after they approve, call again with confirm=true: it uploads the changed media and sends the plan to the game's owner, who approves it in Studio. Nothing was uploaded or changed.`
|
|
287
|
+
: blocked.length
|
|
288
|
+
? "Nothing can change now: read each blocked item's reason."
|
|
289
|
+
: "The Game's items already match summer.items.json.",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (batches.length > 1) {
|
|
293
|
+
throw new BuildToolError("item_plan_too_large", `${pending.length} items would change, and one owner approval takes at most ${MAX_ITEMS_PER_PROPOSAL}. Nothing was uploaded or sent.`, `Recovery: call again with confirm=true and slugs set to one batch at a time (batches lists them, ${MAX_ITEMS_PER_PROPOSAL} per batch, by slug); each batch gets its own approval link.`, undefined, { batches });
|
|
294
|
+
}
|
|
295
|
+
const audit = { at: new Date(deps.now()).toISOString(), operation: "sync_items", face: input.face, gateway, gameId, slugs: pending.map((plan) => plan.slug) };
|
|
296
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "started" });
|
|
297
|
+
try {
|
|
298
|
+
const proposed = [];
|
|
299
|
+
for (const plan of pending) {
|
|
300
|
+
const item = items.find((candidate) => candidate.slug === plan.slug);
|
|
301
|
+
const mediaAssetIds = [];
|
|
302
|
+
for (const file of media.get(item.slug) ?? [])
|
|
303
|
+
mediaAssetIds.push(await uploadMedia(store, gameId, file, project, deps));
|
|
304
|
+
const { media: _paths, ...declared } = item;
|
|
305
|
+
proposed.push({
|
|
306
|
+
slug: item.slug,
|
|
307
|
+
action: plan.action,
|
|
308
|
+
...(plan.action === "update" ? { definitionId: plan.definitionId, basedOnRevisionId: plan.revisionId } : {}),
|
|
309
|
+
item: { ...declared, mediaAssetIds },
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
// One request per exact plan: a retry asks the owner once, not twice.
|
|
313
|
+
const idempotencyKey = createHash("sha256").update(JSON.stringify({ gameId, proposed })).digest("hex").slice(0, 64);
|
|
314
|
+
const answer = await deps.propose({ gameId, items: proposed, idempotencyKey });
|
|
315
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: String(answer.status ?? (answer.ok === false ? "failed" : "sent")) });
|
|
316
|
+
if (answer.ok === false) {
|
|
317
|
+
const error = (answer.error ?? {});
|
|
318
|
+
const code = String(error.code ?? "items_proposal_failed");
|
|
319
|
+
throw new BuildToolError(code, `${String(error.message ?? "Summer did not take the item plan.").replace(/\.?$/, ".")} Nothing changed.`, PROPOSAL_FAILURES[code] ?? "Recovery: the media is uploaded; call again with the same summer.items.json to send the same plan.", undefined, { ...(error.details ? { details: error.details } : {}), ...(error.requestId ? { requestId: error.requestId } : {}) });
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
status: answer.status === "needs_approval" ? "awaiting_owner_approval" : String(answer.status ?? "sent"),
|
|
324
|
+
...summary,
|
|
325
|
+
...(answer.approval ? { approval: answer.approval } : {}),
|
|
326
|
+
next: "Give the owner the approval link. Nothing changes until they approve in Studio; then each item is submitted for Summer's review. Call again later to see the result.",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "failed", code: error instanceof BuildToolError ? error.code : "sync_items_failed" });
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Slugged items the Game has that summer.items.json does not name: listed, never changed. */
|
|
335
|
+
async function listUnmanaged(store, gameId, named) {
|
|
336
|
+
const out = [];
|
|
337
|
+
let cursor = "";
|
|
338
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
339
|
+
const body = await store.call("GET", `games/${seg(gameId)}/item-definitions?limit=100${cursor ? `&cursor=${seg(cursor)}` : ""}`);
|
|
340
|
+
for (const definition of body.items ?? []) {
|
|
341
|
+
if (definition.authority === "PASSPORT_CONTROL")
|
|
342
|
+
continue;
|
|
343
|
+
const slug = typeof definition.slug === "string" ? definition.slug : null;
|
|
344
|
+
if (!slug || !named.has(slug))
|
|
345
|
+
out.push({ slug, definitionId: String(definition.definitionId), name: typeof definition.name === "string" ? definition.name : null });
|
|
346
|
+
}
|
|
347
|
+
if (!body.nextCursor)
|
|
348
|
+
break;
|
|
349
|
+
cursor = body.nextCursor;
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|