diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 50f3050c7..a5c33ab12 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -53,6 +53,7 @@ "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", "lucide-react": "^0.546.0", + "phaser": "^4.2.1", "react": "^19.0.0", "react-arborist": "^3.16.0", "react-colorful": "^5.8.0", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c5e202e03..8ef63ae8d 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -113,6 +113,8 @@ const allowedUncalledTauriCommands = [ 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'stop_local_game_preview_if_matches', + 'start_game_creator_external_mcp', + 'stop_game_creator_external_mcp', ]; const sourceExtensions = new Set([ '.json', diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 397d88dcf..64577d35f 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -127,6 +127,39 @@ function readBackendTargets({ requireAgcBackend = false } = {}) { }); } +function readBackendServiceFailure( + state, + { + expectedDatabase = backendDatabase, + expectedSpacetimeDataDir = backendSpacetimeDataDir, + } = {}, +) { + const targets = resolveBackendTargetsFromState(state, { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir, + }); + if (!targets.hasMatchingBackend) { + return null; + } + + for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) { + const service = state?.services?.[serviceName]; + if (service?.status !== 'failed') { + continue; + } + + return { + serviceName, + failure: service.signal + ? `signal=${service.signal}` + : `code=${service.exitCode ?? 1}`, + }; + } + + return null; +} + async function isBackendReady({ state = readJson(devStackStatePath), isReady = isHttpReady, @@ -505,11 +538,29 @@ async function terminateChildTree( return { stopped, forced: true }; } -async function waitForBackendReady(backendChild, timeoutMs = 600_000) { +async function waitForBackendReady( + backendChild, + timeoutMs = 600_000, + { + checkBackendReady = isBackendReady, + readState = () => readJson(devStackStatePath), + resolveTargets = readBackendTargets, + } = {}, +) { + const initialStateUpdatedAt = readState()?.updatedAt ?? ''; const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { - if (await isBackendReady()) { - return readBackendTargets(); + if (await checkBackendReady()) { + return resolveTargets(); + } + const state = readState(); + if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) { + const serviceFailure = readBackendServiceFailure(state); + if (serviceFailure) { + throw new Error( + `配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`, + ); + } } const failure = readChildFailure(backendChild); if (failure) { @@ -686,6 +737,7 @@ export { isDirectModuleExecution, isProcessGroupAlive, preflightExistingVite, + readBackendServiceFailure, readChildFailure, readExistingViteServer, readLinuxProcessGroupAlive, diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-game-package-lock.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-game-package-lock.json new file mode 100644 index 000000000..45ece2543 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-game-package-lock.json @@ -0,0 +1,1138 @@ +{ + "name": "agc-game", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agc-game", + "dependencies": { + "phaser": "4.2.1" + }, + "devDependencies": { + "vite": "^6.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/phaser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", + "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index 24ad9799a..1cd480a1e 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -1,21 +1,22 @@ --- name: agc-web-game-development -description: Build or modify a playable web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work. +description: Build or modify a playable npm-managed Phaser 4 web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work. --- # AGC Web Game Development -Implement the user's actual game request in the current project. Choose DOM, Canvas, WebGL, or a combination based on the game rather than a fixed code template. +Implement the user's actual game request in the current project as an npm-managed Phaser 4.2.1 game. Use Phaser scenes for gameplay and DOM only for deliberately external UI. ## Workflow -1. Read the existing `index.html`, `style.css`, and `game.js` before modifying an existing game. -2. Keep the entry self-contained and runnable from the AGC loopback preview. Avoid CDN-only dependencies and network-required runtime assets. -3. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. -4. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. -5. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art. -6. Avoid undefined animation callbacks, duplicate loops, stale event listeners, and state that survives restart unintentionally. -7. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. +1. Read the existing package and source files before editing. New projects place `package.json`, `index.html`, `style.css`, and `game.js` under `game/`; existing root packages retain their layout. Run npm in that package directory (for example `npm --prefix game ci` and `npm --prefix game run build`). +2. Keep `package.json` and `package-lock.json` authoritative. Import Phaser with `import Phaser from 'phaser'`; do not copy a bundle, add an import map, or use a CDN. Other npm dependencies are allowed when the game needs them. +3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it. +4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. +5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. +6. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art. +7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally. +8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. When implementing a new game loop or a broad gameplay revision, read `references/game-quality-checklist.md`. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml index b99cbcfe1..3bb189c87 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Web 游戏实现" - short_description: "在当前项目内设计、实现并验证可玩的 HTML、CSS 与 JavaScript 游戏" + short_description: "在当前项目内设计、实现并验证 npm 管理的 Phaser 4 游戏" default_prompt: "Use $agc-web-game-development to build or modify the current playable web game." diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 1fd0458a6..a4c42d7b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.10", + "version": "2026-08-26.12", "skills": [ { "name": "agc-project-structure", @@ -57,7 +57,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "d7748d9ebf4324add0541daf16a2bbec09c4862b85af55bfb369c7f3b99aedff" + "sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a" }, { "name": "agc-browser-playtest", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 8c98b5009..2410c4adf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1330,12 +1330,13 @@ fn codex_app_server_turn_start_params( "approvalPolicy": approval_policy, }); if workspace_mode.allows_workspace_writes() { - // Native project commands stay offline and remain bounded to the - // real game workspace. + // npm install/build must resolve project dependencies. Network access + // is enabled only for DirectProject; writableRoots keeps the file-write + // boundary at the real game workspace. params["sandboxPolicy"] = serde_json::json!({ "type": "workspaceWrite", "writableRoots": [workspace_path], - "networkAccess": false + "networkAccess": true }); } params @@ -2119,6 +2120,9 @@ impl CodexAppServerConnection { if workspace_mode == CodexAppServerWorkspaceMode::DirectProject && llm.web_search_enabled { command.env(DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV, "1"); } + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + command.env("npm_config_cache", workspace_path.join(".npm-cache")); + } command .env("CODEX_HOME", &isolated_codex_home) .env("HOME", &isolated_os_home) @@ -2594,7 +2598,7 @@ impl CodexAppServerConnection { "AGC 直连项目缺少客户端受控工具桥".to_string(), ) })? - .begin_user_turn(direct_codex_current_user_prompt(&request)) + .begin_user_turn() .map_err(platform_llm::LlmError::InvalidRequest)?, ) } else { @@ -4522,7 +4526,7 @@ mod tests { ); assert_eq!( turn.pointer("/sandboxPolicy/networkAccess"), - Some(&serde_json::json!(false)) + Some(&serde_json::json!(true)) ); let authority_paths = [ turn.get("cwd"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 4309d0923..5498335b2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts,完成后必须从 `dist/index.html` 试玩。 原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -72,6 +72,21 @@ fn direct_codex_game_outputs(root: &Path) -> Vec<(String, &'static str, &'static (entry.to_string(), "game-entry", "text/html"), (format!("{prefix}style.css"), "game-style", "text/css"), (format!("{prefix}game.js"), "game-script", "text/javascript"), + ( + format!("{prefix}package.json"), + "game-package", + "application/json", + ), + ( + format!("{prefix}package-lock.json"), + "game-lockfile", + "application/json", + ), + ( + format!("{prefix}vite.config.js"), + "game-build-config", + "text/javascript", + ), ] } @@ -2132,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { let Ok(validated_slices) = validated_art_slices(root) else { return Vec::new(); }; - if validated_slices.len() != 4 { + if validated_slices.is_empty() { return Vec::new(); } let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len()); @@ -2703,6 +2718,7 @@ async fn generate_direct_taonier_art_asset_at( asset_kind: asset_kind.to_string(), asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), + slice_count: None, }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; @@ -2806,9 +2822,9 @@ fn direct_taonier_art_package_result( } else { Vec::new() }; - if includes_spritesheet && slice_paths.len() != 4 { + if includes_spritesheet && slice_paths.is_empty() { slice_warnings.push( - "当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" + "当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" .to_string(), ); } @@ -3184,7 +3200,14 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource { fn direct_codex_output_fingerprint(root: &Path) -> String { let mut hasher = Sha256::new(); - for (local_path, _, _) in direct_codex_game_outputs(root) { + let mut paths: Vec = direct_codex_game_outputs(root) + .into_iter() + .map(|(p, _, _)| p) + .collect(); + paths.extend(direct_npm_source_paths(root)); + paths.sort(); + paths.dedup(); + for local_path in paths { hasher.update(local_path.as_bytes()); hasher.update([0]); let path = root.join(local_path); @@ -3200,6 +3223,70 @@ fn direct_codex_output_fingerprint(root: &Path) -> String { format!("{:x}", hasher.finalize()) } +fn direct_npm_source_paths(root: &Path) -> Vec { + let base = if root.join("package.json").is_file() { + root.to_path_buf() + } else if root.join("game/package.json").is_file() { + root.join("game") + } else { + return Vec::new(); + }; + let mut output = Vec::new(); + let mut pending = vec![(base, 0usize)]; + let mut inspected = 0usize; + while let Some((directory, depth)) = pending.pop() { + if depth > 16 || inspected >= 4096 { + break; + } + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + inspected += 1; + if inspected > 4096 { + break; + } + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if name.starts_with('.') + || matches!( + name.as_str(), + "node_modules" + | "dist" + | "target" + | "memory" + | "exports" + | "auth.json" + | "credentials.json" + | "game-creator.config.json" + | "game-creator.config.local.json" + ) + { + continue; + } + let Ok(kind) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if kind.is_dir() { + pending.push((path, depth + 1)); + } else if kind.is_file() + && matches!( + path.extension().and_then(|e| e.to_str()), + Some("js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "css" | "html" | "json") + ) + { + if let Ok(relative) = path.strip_prefix(root) { + if let Some(value) = relative.to_str() { + output.push(value.replace('\\', "/")); + } + } + } + } + } + output.sort(); + output +} + fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result { let revision = read_game_creator_agent_runtime_project_revision(root) .map(|value| value.revision) @@ -3631,6 +3718,34 @@ fn sync_direct_codex_project_file_projection_at( )?; registered += 1; } + for local_path in direct_npm_source_paths(root) { + if direct_codex_game_outputs(root) + .iter() + .any(|(p, _, _)| p == &local_path) + { + continue; + } + if root.join(&local_path).is_file() { + let media_type = if local_path.ends_with(".css") { + "text/css" + } else if local_path.ends_with(".json") { + "application/json" + } else if local_path.ends_with(".html") { + "text/html" + } else { + "text/javascript" + }; + register_local_asset_at( + root, + &local_path, + "game-source", + media_type, + "direct-codex", + direct_codex_generated_source(), + )?; + registered += 1; + } + } if registered == 0 { return Err("Codex 返回后没有可登记的游戏文件".to_string()); } @@ -7343,7 +7458,7 @@ mod tests { assert_eq!(manifest.versions.len(), 1); assert_eq!(manifest.versions[0].version_id, "initial-1"); assert_eq!(manifest.versions[0].project_revision, 1); - assert_eq!(manifest.versions[0].resource_bindings.len(), 10); + assert_eq!(manifest.versions[0].resource_bindings.len(), 13); for expected_path in DIRECT_CODEX_ART_ASSET_PATHS.into_iter().chain( DIRECT_CODEX_SPRITESHEET_SLICE_PATHS .iter() @@ -7380,7 +7495,7 @@ mod tests { .expect("project revision"); assert_eq!(revision.revision, 2); let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); - assert_eq!(manifest.assets.len(), 10); + assert_eq!(manifest.assets.len(), 13); assert_eq!( manifest .assets @@ -7400,7 +7515,7 @@ mod tests { manifest.versions[1].created_reason, GameIterationVersionCreatedReason::AgentRevision ); - assert_eq!(manifest.versions[1].resource_bindings.len(), 10); + assert_eq!(manifest.versions[1].resource_bindings.len(), 13); let unchanged_fingerprint = direct_codex_output_fingerprint(root.path()); sync_direct_codex_project_outputs_at(root.path(), Some(&unchanged_fingerprint)) @@ -8284,3 +8399,36 @@ mod tests { ); } } + +#[cfg(test)] +mod npm_source_projection_tests { + use super::*; + + #[test] + fn npm_sources_track_nested_modules_but_ignore_dependencies_and_private_files() { + for nested in [false, true] { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = if nested { + root.path().join("game") + } else { + root.path().to_path_buf() + }; + fs::create_dir_all(source.join("src/scenes")).unwrap(); + fs::create_dir_all(source.join("node_modules/demo")).unwrap(); + fs::create_dir_all(source.join(".npm-cache")).unwrap(); + fs::write(source.join("package.json"), "{}").unwrap(); + fs::write(source.join("src/scenes/play.ts"), "export const value = 1;").unwrap(); + fs::write(source.join("node_modules/demo/index.js"), "private dep").unwrap(); + fs::write(source.join(".npm-cache/auth.json"), "private cache").unwrap(); + let paths = direct_npm_source_paths(root.path()); + let prefix = if nested { "game/" } else { "" }; + assert!(paths.contains(&format!("{prefix}src/scenes/play.ts"))); + assert!(!paths + .iter() + .any(|path| path.contains("node_modules") || path.contains(".npm-cache"))); + let before = direct_codex_output_fingerprint(root.path()); + fs::write(source.join("src/scenes/play.ts"), "export const value = 2;").unwrap(); + assert_ne!(direct_codex_output_fingerprint(root.path()), before); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 5b9f4cfdd..bdbeb222c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -63,7 +63,6 @@ struct DirectToolBridgeTurnAuthorization { struct DirectToolBridgeActiveTurnAuthorization { turn_id: String, - allows_regeneration: bool, brief_sha256: Option, completed_result: Option, resource_request_ids: BTreeMap, @@ -181,30 +180,23 @@ impl DirectToolBridge { &self.url } - /// Arm exactly one client-owned Direct turn. The raw user message is used - /// only for this synchronous decision and is never retained by the bridge. - pub(crate) fn begin_user_turn( - &self, - user_prompt: &str, - ) -> Result { - self.state.begin_user_turn(user_prompt) + /// Arm exactly one client-owned Direct turn. Codex chooses the business + /// operation through the reviewed MCP tool and arguments; the bridge only + /// binds that call to the active client turn. + pub(crate) fn begin_user_turn(&self) -> Result { + self.state.begin_user_turn() } } impl DirectToolBridgeState { - fn begin_user_turn( - self: &Arc, - user_prompt: &str, - ) -> Result { + fn begin_user_turn(self: &Arc) -> Result { let turn_id = direct_taonier_active_invocation_id_at(&self.root)?; - let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt); let mut authorization = self .turn_authorization .lock() .map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?; authorization.active = Some(DirectToolBridgeActiveTurnAuthorization { turn_id: turn_id.clone(), - allows_regeneration, brief_sha256: None, completed_result: None, resource_request_ids: BTreeMap::new(), @@ -594,12 +586,9 @@ impl DirectToolBridgeState { .active .as_mut() .ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?; - if !active.allows_regeneration { - return Err("当前用户消息未显式授权重新生成或替换美术".to_string()); - } match active.brief_sha256.as_deref() { Some(expected) if expected != brief_sha256 => { - return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string()) + return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string()) } None => active.brief_sha256 = Some(brief_sha256.clone()), Some(_) => {} @@ -2116,6 +2105,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) asset_kind: kind.clone(), asset_label: asset_name.clone(), replace_existing: false, + slice_count: None, }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( @@ -2736,106 +2726,20 @@ mod tests { } #[test] - fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() { - for prompt in [ - "继续修复布局", - "解释一下重新生成美术是什么意思", - "不要重新生成美术,只调整代码", - "别换一套美术,继续用现在这套", - "解释一下换一套美术按钮", - "是否要改变视觉风格?", - "Do not regenerate the art; keep the current package.", - "I don't want to change the visual style.", - "What does use a new art set mean?", - "文案写着“换一套美术”", - "Yesterday I said regenerate art, but today keep it.", - "Please explain how to regenerate art.", - "重新生成美术以后再说,现在只修代码", - "重做美术先不做,先改玩法", - "Regenerate the art maybe later; for now just fix the code.", - "把按钮文案改成“请重新生成美术”,不要执行生成工具", - "把按钮文案改成‘请重新生成美术’,不要执行生成工具", - "Change the button label to 'please regenerate the art'; do not execute it.", - "用户之前说请重新生成美术,我只是在复述", - "Yesterday the user said please regenerate the art; I am just quoting it.", - "以后请重新生成美术,现在先改代码", - "你能不能帮我重新生成美术,顺便解释一下价格", - "请重新生成美术吗", - "请重新生成美术吗,还是只改代码", - "请重新生成美术或者只改代码", - "请重新生成美术以外的内容", - "请重新生成美术,但不要执行生成工具", - "不需要重新生成美术", - "界面上显示:请重新生成美术", - "界面标题是请重新生成美术", - "产品经理让我写请重新生成美术", - "下周请重新生成美术", - "他说«请重新生成美术»", - "Could you please regenerate the art", - "Please regenerate the art? Or only fix code.", - "Please regenerate the art except for the paid generation.", - "Please regenerate the art, but do not execute the tool.", - "Please regenerate the art, but don’t execute the tool.", - "Please regenerate the art, but I don't authorize this paid generation.", - "Please regenerate the art, but I don‘t authorize this paid generation.", - "Please regenerate the art, but do not execute the paid tool.", - "Please regenerate the art, but never execute the paid tool.", - "Please regenerate the art, but avoid executing the paid tool.", - "Please regenerate the art, but 'do not execute the tool", - "Please regenerate the art only if it is free.", - "Please regenerate the art only after I confirm the charge.", - "Please regenerate the art, but do “not” execute the paid tool.", - "请重新生成美术,三天后再执行。", - "请重新生成美术,得到我的许可再做。", - "请重新生成美术,地面需要无缝循环。", - "Please regenerate the art, but skip the paid generation.", - "请重新生成美术【生成操作跳过】", - "请重新生成美术【仅在零元时执行】", - "Please regenerate the art “but skip the paid generation”", - "Please regenerate the art; alternatively, just fix the code.", - "Please regenerate the art, but do n\u{200B}ot execute the paid tool.", - "Please regenerate the art with a clay style.", - "I don't need you to regenerate the art", - "The UI shows: please regenerate the art", - "Please regenerate the art next week", - "He said «please regenerate the art»", - ] { - assert!( - !direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must fail closed: {prompt}" - ); - } - for prompt in [ - "请重新生成美术。", - "那就请重新生成美术!", - "换一套美术", - "Please regenerate the art!", - ] { - assert!( - direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must explicitly authorize: {prompt}" - ); - } - + fn regenerate_uses_current_client_turn_and_one_stable_brief() { let root = tempfile::tempdir().expect("stable client turn root"); let state = direct_tool_bridge_state(root.path().to_path_buf()); - assert!(state.begin_user_turn("请重新生成美术").is_err()); + assert!(state.begin_user_turn().is_err()); let client_turn_id = "client-turn-stable-0001"; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id) .expect("client-owned stable invocation"); - let ordinary_turn = state - .begin_user_turn("继续优化交互") - .expect("ordinary turn authorization state"); - assert!(state.authorize_regeneration_call("陶泥风格").is_err()); - drop(ordinary_turn); - - let authorized_turn = state - .begin_user_turn("请重新生成美术") - .expect("authorized regeneration turn"); + let active_turn = state + .begin_user_turn() + .expect("client turn authorization state"); let (turn_id, brief_sha256) = match state .authorize_regeneration_call("陶泥风格") - .expect("first stable regeneration call") + .expect("MCP mode selects regeneration explicitly") { DirectToolBridgeRegenerationCall::Execute { turn_id, @@ -2864,7 +2768,7 @@ mod tests { panic!("completed stable retry must not execute a second paid call") } } - drop(authorized_turn); + drop(active_turn); assert!(state.authorize_regeneration_call("陶泥风格").is_err()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 30e3b0bea..3dde7a64a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1,7 +1,13 @@ use super::*; +use axum::extract::{DefaultBodyLimit, State as AxumState}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::post; +use axum::{Json, Router}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; pub(crate) const DIRECT_TOOLS_MCP_MODE_FLAG: &str = "--agc-direct-tools-mcp"; pub(crate) const DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV: &str = @@ -14,6 +20,37 @@ const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; +const EXTERNAL_MCP_RESPONSE_MAX_CHARS: usize = 256 * 1024; +const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; +const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; +const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; +static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); +static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); +tokio::task_local! { + static EXTERNAL_MCP_BRIDGE_URL: String; +} + +pub(crate) struct ExternalMcpServer { + _bridge: super::direct_tool_bridge::DirectToolBridge, + pub(crate) url: String, + pub(crate) token: String, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for ExternalMcpServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[derive(Clone)] +struct ExternalMcpHttpState { + bridge_url: String, + root: PathBuf, + token: String, + session_user_id: String, + session_generation: u64, +} pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { args == [DIRECT_TOOLS_MCP_MODE_FLAG] @@ -43,6 +80,62 @@ fn direct_tools_mcp_specs() -> Value { fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { let tools = vec![ + json!({ + "name": "client.session.info", + "description": "返回当前已绑定的 AGC 客户端会话和项目安全摘要;不返回宿主路径、凭据或内部地址。", + "inputSchema": { "type": "object", "additionalProperties": false } + }), + json!({ + "name": "conversation.record_codex_response", + "description": "显式记录外部 Codex 的一条最终返回。客户端只保存有界、脱敏后的正文和安全摘要,不根据正文触发业务动作。", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "sequence": { "type": "integer", "minimum": 0, "maximum": 1000000 }, + "content": { "type": "string", "minLength": 1, "maxLength": EXTERNAL_MCP_RESPONSE_MAX_CHARS } + }, + "required": ["requestId", "sequence", "content"], + "additionalProperties": false + } + }), + json!({ + "name": "conversation.list", + "description": "按序读取当前项目已记录的 Codex 返回摘要。", + "inputSchema": { + "type": "object", + "properties": { + "offset": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 } + }, + "additionalProperties": false + } + }), + json!({ + "name": "conversation.read", + "description": "读取当前项目的一条已记录 Codex 返回;只能使用 conversation.list 返回的 recordId。", + "inputSchema": { + "type": "object", + "properties": { + "recordId": { "type": "string", "minLength": 1, "maxLength": 80 } + }, + "required": ["recordId"], + "additionalProperties": false + } + }), + json!({ + "name": "agc_read_skill_resource", + "description": "读取审核通过的 AGC Skill 指导文件;仅允许清单内 skillName 和相对文件名。", + "inputSchema": { + "type": "object", + "properties": { + "skillName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "relativePath": { "type": "string", "minLength": 1, "maxLength": 240 } + }, + "required": ["skillName", "relativePath"], + "additionalProperties": false + } + }), json!({ "name": "agc_write_file", "description": "把文本写入当前 AGC 项目的相对路径。Codex 可以按需使用它直接推进代码、配置、资源依赖或说明文件;客户端只负责项目路径和基本控制面边界,不要求固定文件、任务顺序、验证或完成回执。", @@ -67,7 +160,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), json!({ "name": "taonier_prepare_game_art", - "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。regenerate 还必须通过客户端对当前用户消息签发的单回合稳定调用授权;模型参数和 MCP 自动批准本身不构成替换授权。仅在用户意图确实需要新美术时调用。", + "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。Codex 根据当前对话决定是否调用 regenerate;客户端不解析用户文本,也不替 Codex 判断意图。", "inputSchema": { "type": "object", "properties": { @@ -81,7 +174,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "type": "string", "enum": ["reuse-or-create", "regenerate"], "default": "reuse-or-create", - "description": "缺省安全复用有效美术包;只有用户明确要求换一套或重新生成时使用 regenerate" + "description": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate" } }, "required": ["brief"], @@ -763,7 +856,9 @@ fn tool_search_max_results(arguments: &Value) -> Result { } fn direct_tool_bridge_url() -> Result { - let value = std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV) + let value = EXTERNAL_MCP_BRIDGE_URL + .try_with(Clone::clone) + .or_else(|_| std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV)) .map_err(|_| "客户端受控工具桥未配置".to_string())?; let parsed = url::Url::parse(&value).map_err(|_| "客户端受控工具桥地址无效".to_string())?; let host = parsed @@ -974,6 +1069,31 @@ async fn call_agc_web_search(arguments: &Value) -> Value { call_agc_web_search_with_enabled(arguments, controlled_web_search_enabled()).await } +fn call_agc_read_skill_resource(arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["skillName", "relativePath"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let skill = match bounded_tool_string(arguments, "skillName", 120) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let relative = match bounded_tool_string(arguments, "relativePath", 240) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if Path::new(&relative).is_absolute() + || relative.contains("..") + || relative.contains(':') + || relative.contains('\\') + { + return mcp_tool_result("Skill 资源路径不安全".to_string(), Vec::new(), true); + } + match read_agc_skill_resource(&format!("{skill}/{relative}")) { + Ok(content) => mcp_tool_result(content, Vec::new(), false), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> Value { if !enabled { return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true); @@ -997,7 +1117,348 @@ async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> V .await } -async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option { +fn external_mcp_journal_path(root: &Path) -> PathBuf { + root.join(EXTERNAL_MCP_JOURNAL_RELATIVE_PATH) +} + +fn redact_external_mcp_response(content: &str) -> String { + content + .lines() + .map(|line| { + let lower = line.to_ascii_lowercase(); + let sensitive = [ + "authorization:", + "cookie:", + "set-cookie:", + "api_key", + "apikey", + "access_token", + "refresh_token", + "client_secret", + "password:", + "bearer ", + ] + .iter() + .any(|marker| lower.contains(marker)); + if sensitive { + "[redacted sensitive response line]".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") +} + +fn external_mcp_response_summary(content: &str) -> String { + let normalized = content.split_whitespace().collect::>().join(" "); + normalized + .chars() + .take(EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS) + .collect() +} + +fn external_mcp_session_id(root: &Path) -> String { + let mut material = root.to_string_lossy().into_owned(); + if let Some(session) = current_platform_session() { + material.push('\0'); + material.push_str(&session.user_id); + material.push('\0'); + material.push_str(&session.generation.to_string()); + } + format!("mcp-{:x}", Sha256::digest(material.as_bytes())) +} + +fn external_mcp_project_id(root: &Path) -> String { + std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| { + value + .get("projectId") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| { + format!( + "project-{:x}", + Sha256::digest(root.to_string_lossy().as_bytes()) + ) + }) +} + +fn external_mcp_account_id() -> String { + current_platform_session() + .map(|session| format!("account-{:x}", Sha256::digest(session.user_id.as_bytes()))) + .unwrap_or_else(|| "account-unknown".to_string()) +} + +fn validate_external_mcp_record_arguments( + arguments: &Value, +) -> Result<(String, u64, String), String> { + validate_tool_object_fields(arguments, &["requestId", "sequence", "content"])?; + let request_id = bounded_tool_string(arguments, "requestId", 160)?; + let sequence = arguments + .get("sequence") + .and_then(Value::as_u64) + .ok_or_else(|| "工具参数 sequence 必须是非负整数".to_string())?; + if sequence > 1_000_000 { + return Err("工具参数 sequence 超出安全边界".to_string()); + } + let content = arguments + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "工具参数 content 必须是字符串".to_string())?; + if content.is_empty() || content.chars().count() > EXTERNAL_MCP_RESPONSE_MAX_CHARS { + return Err("工具参数 content 不能为空或超过大小上限".to_string()); + } + if content + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err("工具参数 content 不能包含控制字符".to_string()); + } + Ok((request_id, sequence, content.to_string())) +} + +fn read_external_mcp_journal(root: &Path) -> Result, String> { + let path = external_mcp_journal_path(root); + let Ok(bytes) = std::fs::read(&path) else { + return Ok(Vec::new()); + }; + if bytes.len() as u64 > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return Err("Codex 返回记录超过客户端保留上限".to_string()); + } + bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| { + serde_json::from_slice::(line).map_err(|_| "Codex 返回记录格式损坏".to_string()) + }) + .collect() +} + +fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { + if let Err(error) = enforce_project_permission_policy(root, "conversation.write") { + return mcp_tool_result(error, Vec::new(), true); + } + let (request_id, sequence, content) = match validate_external_mcp_record_arguments(arguments) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let redacted = redact_external_mcp_response(&content); + let key = format!("{request_id}\u{0}{sequence}"); + let message_id = format!("external-codex-{:x}", Sha256::digest(key.as_bytes())); + let guard = EXTERNAL_MCP_JOURNAL_LOCK + .get_or_init(|| Mutex::new(())) + .lock(); + if guard.is_err() { + return mcp_tool_result("Codex 返回记录锁不可用".to_string(), Vec::new(), true); + } + let mut records = match read_external_mcp_journal(root) { + Ok(records) => records, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if let Some(existing) = records.iter().find(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + && record.get("sequence").and_then(Value::as_u64) == Some(sequence) + }) { + return mcp_tool_result(existing.to_string(), Vec::new(), false); + } + if let Some(max_sequence) = records + .iter() + .filter(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + }) + .filter_map(|record| record.get("sequence").and_then(Value::as_u64)) + .max() + { + if sequence != max_sequence.saturating_add(1) { + return mcp_tool_result( + "工具参数 sequence 必须按 requestId 连续递增".to_string(), + Vec::new(), + true, + ); + } + } else if sequence != 0 { + return mcp_tool_result( + "同一 requestId 的首条记录 sequence 必须为 0".to_string(), + Vec::new(), + true, + ); + } + let path = external_mcp_journal_path(root); + if let Some(parent) = path.parent() { + if let Err(error) = std::fs::create_dir_all(parent) { + return mcp_tool_result( + format!("创建 Codex 返回记录目录失败:{error}"), + Vec::new(), + true, + ); + } + } + let record = json!({ + "recordId": uuid::Uuid::new_v4().to_string(), + "recordType": "codex.response", + "accountId": external_mcp_account_id(), + "projectId": external_mcp_project_id(root), + "sessionId": external_mcp_session_id(root), + "requestId": request_id, + "sequence": sequence, + "receivedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + "content": redacted, + "contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())), + "summary": external_mcp_response_summary(&redacted), + "truncated": false, + "status": "completed" + }); + let line = match serde_json::to_string(&record) { + Ok(line) => line, + Err(error) => { + return mcp_tool_result( + format!("序列化 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ) + } + }; + let current_size = std::fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return mcp_tool_result( + "Codex 返回记录达到客户端保留上限".to_string(), + Vec::new(), + true, + ); + } + let _project_lock = match acquire_project_write_lock(root, "conversation.write") { + Ok(lock) => lock, + Err(error) => { + return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true) + } + }; + let append_result = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut file| { + use std::io::Write as _; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + file.sync_data() + }); + if let Err(error) = append_result { + return mcp_tool_result( + format!("写入 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ); + } + // Reuse the existing conversation projection so the current UI can read + // the explicit external response without treating it as business truth. + if let Err(error) = append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: record + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + agent_id: None, + }, + &message_id, + ) { + return mcp_tool_result( + format!("Codex 返回已写入但对话投影失败:{error}"), + Vec::new(), + true, + ); + } + records.push(record.clone()); + mcp_tool_result(record.to_string(), Vec::new(), false) +} + +fn external_mcp_session_info(root: &Path) -> Value { + let manifest = std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + let project_id = manifest + .as_ref() + .and_then(|value| value.get("projectId")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + mcp_tool_result( + json!({ + "status": "bound", + "projectId": project_id, + "sessionId": external_mcp_session_id(root), + "transport": "loopback-or-stdio" + }) + .to_string(), + Vec::new(), + false, + ) +} + +fn external_mcp_conversation_list(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["offset", "limit"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let offset = arguments.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize; + let limit = arguments.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize; + if offset > 10_000 || !(1..=100).contains(&limit) { + return mcp_tool_result( + "conversation.list 分页参数超出安全边界".to_string(), + Vec::new(), + true, + ); + } + match read_external_mcp_journal(root) { + Ok(records) => mcp_tool_result( + json!({ "entries": records.into_iter().skip(offset).take(limit).map(|record| json!({ + "recordId": record.get("recordId"), "requestId": record.get("requestId"), + "sequence": record.get("sequence"), "receivedAt": record.get("receivedAt"), + "summary": record.get("summary"), "status": record.get("status") + })).collect::>() }) + .to_string(), + Vec::new(), + false, + ), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +fn external_mcp_conversation_read(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["recordId"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let record_id = match bounded_tool_string(arguments, "recordId", 80) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + match read_external_mcp_journal(root) { + Ok(records) => records + .into_iter() + .find(|record| { + record.get("recordId").and_then(Value::as_str) == Some(record_id.as_str()) + }) + .map(|record| mcp_tool_result(record.to_string(), Vec::new(), false)) + .unwrap_or_else(|| { + mcp_tool_result("未找到 Codex 返回记录".to_string(), Vec::new(), true) + }), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; if id.is_none() { @@ -1014,7 +1475,10 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option id, json!({ "protocolVersion": requested_protocol, - "capabilities": { "tools": { "listChanged": false } }, + "capabilities": { + "tools": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false } + }, "serverInfo": { "name": "genarrative-agc-tools", "version": env!("CARGO_PKG_VERSION") @@ -1023,6 +1487,75 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option )) } "ping" => Some(mcp_success(id, json!({}))), + "resources/list" => Some(mcp_success( + id, + json!({ + "resources": [{ + "uri": "agc://skills/index", + "name": "AGC Skill 索引", + "description": "审核通过的客户端 Skill 与工具使用指导", + "mimeType": "text/plain" + }, { + "uri": "agc://conversation/codex-responses", + "name": "Codex 返回记录", + "description": "当前项目中由 conversation.record_codex_response 写入的只读 journal", + "mimeType": "application/x-ndjson" + }] + }), + )), + "resources/read" => { + let uri = request + .pointer("/params/uri") + .and_then(Value::as_str) + .unwrap_or_default(); + if uri == "agc://skills/index" { + let text = render_agc_skill_pack_index() + .map_err(|_| ()) + .unwrap_or_else(|_| "AGC Skill 索引暂不可用".to_string()); + return Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )); + } + if let Some(resource) = uri.strip_prefix("agc://skills/") { + return match read_agc_skill_resource(resource) { + Ok(text) if text.len() <= DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES => { + Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )) + } + Ok(_) => Some(mcp_error(id, -32000, "AGC Skill 资源超过响应大小上限")), + Err(_) => Some(mcp_error(id, -32602, "未知或未审核的 AGC Skill 资源")), + }; + } + if uri != "agc://conversation/codex-responses" { + Some(mcp_error(id, -32602, "未知资源")) + } else { + let text = read_external_mcp_journal(root) + .map(|records| { + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if text.len() > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES { + return Some(mcp_error(id, -32000, "Codex 返回记录资源超过响应大小上限")); + } + Some(mcp_success( + id, + json!({ + "contents": [{ + "uri": uri, + "mimeType": "application/x-ndjson", + "text": text + }] + }), + )) + } + } "tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())), "tools/call" => { let tool = request @@ -1034,6 +1567,13 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option .cloned() .unwrap_or_else(|| json!({})); let result = match tool { + "client.session.info" => external_mcp_session_info(root), + "conversation.record_codex_response" => { + external_mcp_record_response(root, &arguments) + } + "conversation.list" => external_mcp_conversation_list(root, &arguments), + "conversation.read" => external_mcp_conversation_read(root, &arguments), + "agc_read_skill_resource" => call_agc_read_skill_resource(&arguments), "agc_write_file" => call_agc_write_file(&arguments).await, "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, "agc_generate_image" => call_agc_generate_image(&arguments).await, @@ -1122,6 +1662,108 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { Ok(()) } +fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|value| value == token) +} + +async fn handle_external_mcp_http_request( + AxumState(state): AxumState, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + if !external_mcp_authorized(&headers, &state.token) { + return Err(StatusCode::UNAUTHORIZED); + } + let Some(session) = current_platform_session() else { + return Err(StatusCode::UNAUTHORIZED); + }; + if session.user_id != state.session_user_id || session.generation != state.session_generation { + return Err(StatusCode::UNAUTHORIZED); + } + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + state.bridge_url.clone(), + handle_direct_tools_mcp_request(&state.root, request), + ) + .await + .ok_or(StatusCode::BAD_REQUEST)?; + Ok(Json(response)) +} + +pub(crate) async fn start_external_mcp_loopback( + root: &Path, + controlled_web_search: bool, +) -> Result<(String, String), String> { + let root = validate_direct_tools_project_root(root)?; + let session = current_platform_session() + .ok_or_else(|| "启动客户端 MCP 前必须先完成账号会话绑定".to_string())?; + let token = uuid::Uuid::new_v4().to_string(); + let route = format!("/mcp-{}", uuid::Uuid::new_v4().simple()); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|error| format!("启动客户端 MCP loopback 失败:{error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?; + let bridge = + super::direct_tool_bridge::start_direct_tool_bridge(&root, controlled_web_search).await?; + let state = ExternalMcpHttpState { + bridge_url: bridge.url().to_string(), + root, + token: token.clone(), + session_user_id: session.user_id, + session_generation: session.generation, + }; + let app = Router::new() + .route(&route, post(handle_external_mcp_http_request)) + .layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)) + .with_state(state); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let url = format!("http://127.0.0.1:{}{route}", address.port()); + let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None)); + let mut guard = registry + .lock() + .map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?; + if let Some(previous) = guard.take() { + drop(previous); + } + *guard = Some(ExternalMcpServer { + _bridge: bridge, + url: url.clone(), + token: token.clone(), + task, + }); + Ok((url, token)) +} + +pub(crate) fn stop_external_mcp_loopback() { + if let Some(registry) = EXTERNAL_MCP_SERVER.get() { + if let Ok(mut guard) = registry.lock() { + guard.take(); + } + } +} + +#[tauri::command] +pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let (url, token) = start_external_mcp_loopback(root, false).await?; + Ok(json!({ "url": url, "token": token, "transport": "streamable-http" })) +} + +#[tauri::command] +pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { + stop_external_mcp_loopback(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1210,6 +1852,11 @@ mod tests { assert_eq!( names, vec![ + "client.session.info", + "conversation.record_codex_response", + "conversation.list", + "conversation.read", + "agc_read_skill_resource", "agc_write_file", "taonier_prepare_game_art", "agc_generate_image", @@ -1243,9 +1890,10 @@ mod tests { "reuse-or-create" ); assert_eq!(art_tool["inputSchema"]["required"], json!(["brief"])); - assert!(art_tool["description"].as_str().is_some_and( - |description| description.contains("模型参数和 MCP 自动批准本身不构成替换授权") - )); + assert!(art_tool["description"].as_str().is_some_and(|description| { + description.contains("Codex 根据当前对话决定是否调用 regenerate") + && description.contains("客户端不解析用户文本") + })); assert!(art_tool["description"].as_str().is_some_and(|description| { description.contains("用户不需要提供、配置、粘贴或创建 API Key") && description.contains("不得向用户索要凭据或暴露内部 URL") @@ -1583,4 +2231,53 @@ mod tests { assert_eq!(response["isError"], true); assert!(response.to_string().contains("未审核字段")); } + + #[test] + fn skill_resource_tool_rejects_unreviewed_paths() { + let accepted = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md" + })); + assert_eq!(accepted["isError"], false); + assert!(accepted.to_string().contains("drive prefix")); + + let denied = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "../../auth.json" + })); + assert_eq!(denied["isError"], true); + + let denied_windows_absolute = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": r"C:\temp\SKILL.md" + })); + assert_eq!(denied_windows_absolute["isError"], true); + } + + #[test] + fn external_codex_response_redacts_sensitive_lines_and_keeps_safe_text() { + let response = redact_external_mcp_response( + "完成了页面布局\nAuthorization: Bearer secret-value\n下一步请运行试玩", + ); + assert!(response.contains("完成了页面布局")); + assert!(response.contains("下一步请运行试玩")); + assert!(!response.contains("secret-value")); + } + + #[test] + fn external_codex_response_arguments_reject_unknown_fields_and_control_bytes() { + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "ok", + "unexpected": true + })) + .is_err()); + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "bad\u{0001}" + })) + .is_err()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index f9c5a4ab3..8d3977878 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -78,7 +78,7 @@ pub(crate) use draft_validation::{ validate_llm_agent_handoffs, validate_llm_game_draft, validate_non_placeholder_game_html, validate_playable_game_html, validate_safe_game_html_runtime, }; -pub(crate) use draft_writer::write_local_game_draft_at; +pub(crate) use draft_writer::{ensure_legacy_json_generator_project, write_local_game_draft_at}; #[allow(unused_imports)] pub(crate) use loop_orchestration::{ emit_agent_progress, game_creator_agent_llm_error_is_mud_points_insufficient, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index e3cee4f47..0e7a97084 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -412,6 +412,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) asset_kind: String, pub(crate) asset_label: String, pub(crate) replace_existing: bool, + pub(crate) slice_count: Option, } impl Default for PlatformArtAssetGenerationOptions { @@ -423,6 +424,7 @@ impl Default for PlatformArtAssetGenerationOptions { asset_kind: "game-art".to_string(), asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, + slice_count: None, } } } @@ -681,6 +683,47 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) } +/// Observe an accepted operation once without waiting. A single GET that +/// reports `failed` is authoritative and allows a changed retry to release +/// the old local slot; queued/running/unknown outcomes remain protected. +async fn accepted_generation_is_authoritatively_failed_once( + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + submission_payload: &serde_json::Value, +) -> Result { + let submission = external_editor_response_data(submission_payload); + let operation_id = json_string_field(submission, "operationId") + .ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?; + access.validate_frozen_session()?; + let payload = tokio::time::timeout( + Duration::from_secs(3), + external_editor_json_request( + client + .get(format!( + "{}{}", + access.api_base_url(), + access.generation_status_route(&operation_id) + )) + .bearer_auth(access.bearer_token()), + "查询平台图片生成任务", + ), + ) + .await + .map_err(|_| "查询平台图片生成任务超时".to_string())??; + access.validate_frozen_session()?; + let generation = platform_generation_status_data(&payload); + match json_string_field(generation, "status").as_deref() { + Some("failed") => Ok(true), + Some("queued" | "running" | "completed") => Ok(false), + Some(status) => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status};operationId={operation_id}" + )), + None => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 status;operationId={operation_id}" + )), + } +} + pub(crate) async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, @@ -1714,24 +1757,16 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // long creation request cannot reject the atlas before it is queued. const MAX_DESCRIPTION_CHARS: usize = 200; const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; - [ - "第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材", - "第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材", - "第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材", - "第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材", - ] - .into_iter() - .map(|category| { - let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( - category - .chars() - .count() - .saturating_add(CONTEXT_PREFIX.chars().count()), - ); - let project_context = truncate_inline_bounded(prompt.trim(), context_budget); - format!("{category}{CONTEXT_PREFIX}{project_context}") - }) - .collect() + let category = + "按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定"; + let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( + category + .chars() + .count() + .saturating_add(CONTEXT_PREFIX.chars().count()), + ); + let project_context = truncate_inline_bounded(prompt.trim(), context_budget); + vec![format!("{category}{CONTEXT_PREFIX}{project_context}")] } fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { @@ -2043,13 +2078,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( } let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); let runtime_context = - standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?; + standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( root, prompt, briefs, options, - true, + false, &runtime_context, ) .await @@ -2427,6 +2462,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.generation_prompt != generation_prompt { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2448,6 +2511,36 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.reference_resource_ids != [current_reference] { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = + platform_art_generation_runtime_submission_payload(&state) + { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2556,7 +2649,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at serde_json::json!({ "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), - "sliceLayout": "grid-2x2", + "sliceCount": options.slice_count, "screenColor": "auto", "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, @@ -6259,11 +6352,8 @@ fn validate_strict_platform_art_spritesheet_contract( has_transparent_pixels: bool, has_visible_pixels: bool, ) -> Result<(), String> { - if slices.len() != 4 { - return Err(format!( - "strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个", - slices.len() - )); + if slices.is_empty() { + return Err("spritesheet 图集至少需要一个独立切片".to_string()); } let resource_id = resource_id .map(str::trim) @@ -6294,12 +6384,7 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string()); } - if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") { - return Err( - "strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成" - .to_string(), - ); - } + let _requested_slice_layout = spritesheet_slice_layout; if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -6634,7 +6719,7 @@ fn existing_platform_art_slice_registrations_are_complete( manifest: &GameCreationAppManifest, registrations: &[PlatformArtSliceManifestRegistration], ) -> Result { - if registrations.len() != 4 { + if registrations.is_empty() { return Ok(false); } let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len()); @@ -7594,6 +7679,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "手工背景".to_string(), replace_existing: true, + slice_count: None, }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -9402,6 +9488,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "整包规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10239,6 +10326,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "整包背景图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10690,6 +10778,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11264,6 +11353,7 @@ mod canvas_generation_tests { asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, + slice_count: None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs index b3a84948e..ac67a164a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs @@ -9,8 +9,8 @@ pub(crate) fn write_local_game_draft_at( if prompt.is_empty() { return Err("创作想法不能为空".to_string()); } + ensure_legacy_json_generator_project(root)?; validate_llm_game_draft(prompt, draft)?; - init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; let checkpoint = create_local_project_checkpoint_at(root)?; let timestamp = unix_timestamp(); let title = draft.title.trim(); @@ -134,3 +134,38 @@ pub(crate) fn write_local_game_draft_at( manifest, }) } + +pub(crate) fn ensure_legacy_json_generator_project(root: &Path) -> Result<(), String> { + if root.join("game/package.json").exists() + || root.join("package.json").exists() + || !root.join("game/index.html").is_file() + || !root.join(".agent/manifest.json").is_file() + { + return Err("JSON Generator 仅支持已有的单文件 HTML 项目;新建游戏与 npm / Phaser 4 项目请使用 DirectProject 完成依赖安装、构建和试玩".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod legacy_generator_tests { + use super::*; + + #[test] + fn legacy_generator_rejects_npm_before_writing() { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + fs::create_dir_all(root.path().join("game")).unwrap(); + fs::create_dir_all(root.path().join(".agent")).unwrap(); + fs::write(root.path().join("game/index.html"), "legacy source").unwrap(); + fs::write(root.path().join(".agent/manifest.json"), "{}").unwrap(); + assert!(ensure_legacy_json_generator_project(root.path()).is_ok()); + fs::write(root.path().join("game/package.json"), "{}").unwrap(); + assert!(ensure_legacy_json_generator_project(root.path()) + .unwrap_err() + .contains("DirectProject")); + assert_eq!( + fs::read_to_string(root.path().join("game/index.html")).unwrap(), + "legacy source" + ); + assert!(!root.path().join(".agent/spec.md").exists()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index e955f53a4..a79d03fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -44,6 +44,7 @@ pub(crate) async fn run_game_creator_agent_loop_at( project_blackboard: &str, progress: Option<&AgentProgressEmitter<'_>>, ) -> Result { + ensure_legacy_json_generator_project(root)?; let spec_path = root.join(".agent/spec.md"); let findings_path = root.join(".agent/findings.md"); let run_id = format!("game-generate-draft-{}", unix_millis()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 4f65ace3c..60f63acc3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -24,6 +24,7 @@ JSON schema: } gameHtml 规则: +- 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。 - 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。 - 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。 - JavaScript 不要 eval、Function、localStorage、fetch、WebSocket、ServiceWorker。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 7648408da..460d8dfad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -488,15 +488,13 @@ fn game_creator_design_foundation_tool_plan_prompt( prompt: &str, editor_api_key_is_configured: bool, ) -> String { - let role_boundary = "角色边界:项目文件写入只允许 memory/project.md 与 game/game_design.md;配置 External Editor API Key 且任务要求界面原型时,可额外产出 assets/ui-prototype.png 与 Runtime 发现清单要求的 assets/ui-pages/*.png;UI 设计图生成后只能通过受控 ui.workflow.run 写入或关联 UI JSON、保存工作流阶段并应用页面,不得绕过该工具直接写入 UI State。不得创建、修改、删除或补丁 game/index.html,也不得改动任何其他程序实现、发布、音频或美术素材文件。完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人 owner 产物;不得调用 project.verify、command.run_limited、game.static_smoke、preview.start 或 preview.validate,也不得通过 command.exec、command.start 或其他工具启动本地预览服务、浏览器、Playwright,或执行任何桌面端、移动端试玩验证。完整 DAG 的最终静态验收仍属于 preview-readiness,浏览器验收仍属于 preview-playtest。"; + let role_boundary = "角色边界:只负责玩法规格、界面建议和视觉工具使用指导。项目文件与图片输出必须服从当前任务明确要求;不创建固定图片槽位,不规定固定数量或布局,不修改 game/index.html,不启动预览或试玩。"; if !editor_api_key_is_configured { return format!( "{prompt}\n\n你负责玩法规格与界面原型基础交付。当前未配置 External Editor API Key,因此本轮必须完成 memory/project.md 与 game/game_design.md,不调用 canvas.asset_generate,也不伪造 assets/ui-prototype.png。把界面结构、控件、状态和双视口要求写进玩法规格;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},供 Runtime 自动发现和后续程序组实现;完成写入后直接交付,不要自行运行任何验证命令。{role_boundary}" ); } - format!( - "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、实体、资源、目标名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},作为 Runtime 自动发现的权威设计声明。不得沿用或近似改写知名游戏单位、角色、Logo、界面术语或受保护视觉语言。文本策划只是中间结果;最终必须先用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,再调用 canvas.asset_generate 生成 16:9、2K 横屏界面原型图并登记到 assets/ui-prototype.png,assetKind=ui-prototype、assetLabel=游戏横屏界面原型图、replaceExisting=false。图片 prompt 必须逐项继承当前任务和 game/game_design.md 的真实玩法、HUD、可玩区域、关键实体、主要操作、失败/重开与移动端触控要求;不得假设为塔防或补入合同中不存在的单位卡牌、费用、波次、敌人入口等结构。Runtime 固定把规范图资源作为 referenceImageSrcs 第一项,调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=ui-design);不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions,后者只用于从已有且带标注的 UI 设计图提取独立透明 UI 素材。缺少规范图时必须等待 art-director 依赖并如实阻塞,不得回退为无规范参考的普通生图。canvas.asset_generate 成功只表示候选图片已生成并登记,不等于视觉验收完成。已有同路径画布资产时先核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得重复生成或再次扣费。只有 ui-prototype.v2 的 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme 八项检查全部通过才可完成。八项视觉检查通过后,先调用 ui.workflow.run 的 discover 自动读取受控页面声明,不得凭空猜页面;再按返回的每个 pageId 逐页调用 canvas.asset_generate,以固定 16:9、2K、assetKind=ui-prototype、replaceExisting=false 生成并登记对应 assets/ui-pages/{{pageId}}.png 设计图,assetLabel 使用该页标题,使用发现的真实 applicationPath 依次执行 prepare、recognize、status,确认所有页面均无 blockers 后再执行 finalize。该工具会创建并关联 kind=UI 的 JSON 编辑资源、持久化每一阶段 State、同步 manifest/客户端,并在完成后返回 visual-binding 最终编辑器路由;只登记 ui-prototype 图片或只写计划不算完成。由 Runtime 在收束门内同时核对固定 owner 文档、当前 revision 与视觉证据。纯场景图、概念图、地图、海报或只有角色而没有可玩界面的画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可使用固定输出合同和 replaceExisting=true 原位替换旧候选;不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" - ) + format!("{prompt}\n\n根据当前玩法需求编写规格和界面建议;如需图片,明确说明用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet,再调用 canvas.asset_generate。不要使用固定图片合同。{role_boundary}") } fn game_creator_art_director_tool_plan_prompt( @@ -506,7 +504,7 @@ fn game_creator_art_director_tool_plan_prompt( if !editor_api_key_is_configured { return format!("{prompt}\n\n你负责确定原创视觉方向。当前未配置 External Editor API Key,这是只读协调任务:只完成正式 director 结论并直接交付,不修改项目文件,不调用 canvas.asset_generate,也不伪造 assets/art-spec.png。seed task 中生成规范图的图片产物与验收条款在本轮不适用。"); } - format!("{prompt}\n\n你负责生成项目唯一的统一视觉规范图。视觉方向文档只是中间结果;最终必须调用 canvas.asset_generate,以固定合同 outputPath=assets/art-spec.png、aspectRatio=1:1、imageSize=1K、assetKind=icon-spec、assetLabel=游戏统一视觉规范图、replaceExisting=false 生成真实图片。Runtime 固定调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=spec),并把结果同时登记到同名画布、素材库和项目 manifest。规范图必须覆盖玩家主体、目标物、地块、UI 图标、状态反馈、色板与材质规则,作为后续 UI 和透明图集共同引用的权威资源;不得用 generationInputs.artSpec JSON、纯文本计划、完整游戏截图、海报或普通黑底图集冒充。canvas.asset_generate 成功只表示固定候选已生成并登记,不等于视觉门已经通过;生成成功后直接交付,由 Runtime 在收束时核对当前 revision、Canvas 登记、资源身份和视觉产物门。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可设置 replaceExisting=true 原位替换。生成失败或缺少 resourceId 时不得提交最终回复,也不得把计划写完当成 completed。") + format!("{prompt}\n\n你负责确定原创视觉方向。根据项目实际需要选择 canvas.asset_generate 的 assetKind、outputPath、尺寸、比例和提示词;可以生成一张或多张图片,也可以不生成图片。需要参考图时使用已登记资源 ID,生成后核对返回资源、权限、计费和登记状态;不要假设固定图片名称、数量、素材类别或布局。") } fn game_creator_art_asset_plan_tool_plan_prompt( @@ -519,7 +517,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt( ); } format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceId,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。透明证据核对完成后直接交付,由 Runtime 在收束门内验证本人固定 manifest 产物并复核 Canvas 证据。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" + "{prompt}\n\n你负责按项目实际需求规划和生成美术素材。使用 asset.list 了解已有资源,再按需调用 canvas.asset_generate;数量、文件名、素材类别、切片布局和尺寸由当前需求决定,不得套用固定图片包或固定 2x2。spritesheet 可通过 sliceCount 指定切片数量,也可以生成普通单图或多张独立图片。生成后核对资源登记、透明度、警告和实际使用情况。" ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 3fa69f520..a617d7f67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -453,16 +453,9 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ "首批 art-director 必须是非只读规范图生成任务", )); } - let art_artifacts = + // 图片产物由 Codex 按项目需求决定;不再要求固定 art-spec.png。 + let _art_artifacts = autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; - if !art_artifacts - .iter() - .any(|path| path == "assets/art-spec.png") - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", - )); - } let code_director = code_director.ok_or_else(|| { autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派") @@ -1976,7 +1969,7 @@ mod tests { plan: Vec::new(), actions: vec![ autonomous_initial_delegate("design-director", &[]), - autonomous_initial_delegate("art-director", &["assets/art-spec.png"]), + autonomous_initial_delegate("art-director", &[]), autonomous_initial_delegate("code-director", &[]), ], response: String::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index a3eac4093..d9d062c0f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1199,32 +1199,36 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( agent_id: &str, required_run_id: Option<&str>, ) -> Option { - if !editor_api_key_is_configured() { - return None; - } - let (expected_path, expected_kind, label) = match agent_id { - "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), - "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), - "art-asset-plan" => ( - "assets/art-spritesheet.png", - "art-spritesheet", - "首版美术素材图", - ), - _ => return None, - }; - let manifest = match read_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: format!("无法核对{label},不能完成任务"), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }); + // 图片产物由 Codex 按项目需求选择,不再存在固定视觉资产完成门禁。 + return None; + #[allow(unreachable_code)] + { + if !editor_api_key_is_configured() { + return None; } - }; - if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { - return Some(AgentRuntimeToolObservation { + let (expected_path, expected_kind, label) = match agent_id { + "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), + "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), + "art-asset-plan" => ( + "assets/art-spritesheet.png", + "art-spritesheet", + "首版美术素材图", + ), + _ => return None, + }; + let manifest = match read_manifest_for_project(root) { + Ok(manifest) => manifest, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: format!("无法核对{label},不能完成任务"), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }); + } + }; + if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { + return Some(AgentRuntimeToolObservation { tool: "runtime.visual_asset".to_string(), status: "blocked".to_string(), summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"), @@ -1234,29 +1238,30 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( redact_agent_runtime_project_paths(root, &error, 300), )), }); - } - if agent_id != "design-foundation" { - return None; - } - match ui_prototype_visual_inspection_blocker_detail_at_locked( - root, - agent_id, - required_run_id, - expected_path, - ) { - Ok(None) => None, - Ok(Some(detail)) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), - detail: Some(detail), - }), - Err(error) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }), + } + if agent_id != "design-foundation" { + return None; + } + match ui_prototype_visual_inspection_blocker_detail_at_locked( + root, + agent_id, + required_run_id, + expected_path, + ) { + Ok(None) => None, + Ok(Some(detail)) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), + detail: Some(detail), + }), + Err(error) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }), + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 4ba1c9cb7..2a1b77488 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -294,6 +294,7 @@ pub(crate) async fn generate_local_game_draft_at( if prompt.is_empty() { return Err("创作想法不能为空".to_string()); } + ensure_legacy_json_generator_project(root)?; init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; let short_memory = read_optional_text(&root.join("memory/session.md"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 45571d8c9..326eab51a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1756,11 +1756,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke } pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool { - editor_api_key_is_configured() - && matches!( - task_id, - "art-director" | "design-foundation" | "art-asset-plan" - ) + false } fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String { @@ -1777,11 +1773,7 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask } else { "" }; - let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() { - "art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png(assetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。" - } else { - "任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。" - }; + let visual_requirement = "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。Runtime 只核对实际声明的资源登记,不要求固定图片合同。"; let verification_requirement = match task.id.as_str() { "code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。", task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。", @@ -1810,7 +1802,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( if task.id == "art-director" { if autonomous_manifest_ready_task_requires_visual_asset(&task.id) { return format!( - "{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,不规定固定图片名称、数量、素材类别或布局;生成成功后直接交付结论。" ); } return format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 948eda19a..211da5829 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -534,15 +534,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]); let repair_of_delegation_id = (!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id); - let required_visual_artifact = if editor_api_key_is_configured() { - match target_agent_id.as_str() { - "design-foundation" => Some("assets/ui-prototype.png"), - "art-asset-plan" => Some("assets/art-spritesheet.png"), - _ => None, - } - } else { - None - }; + let required_visual_artifact: Option<&str> = None; if repair_of_delegation_id.is_none() && required_visual_artifact.is_some_and(|required| { !expected_artifacts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 220dae8ed..4d4616f77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -416,39 +416,6 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error); } } - if agent_id == "art-asset-plan" && path == "assets/art-spritesheet.png" { - let manifest = match read_existing_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "无法确认首版美术素材登记状态,未执行删除".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; - } - }; - let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| { - asset.local_path == "assets/art-spritesheet.png" - && asset.kind == "art-spritesheet" - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, &asset.local_path) - .ok() - .is_some_and(|path| path.is_file()) - }); - if registered_fixed_asset_exists { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "首版美术素材已生成并登记,禁止删除固定正式产物".to_string(), - detail: Some( - "path=assets/art-spritesheet.png · 请复用现有画布资产并核对 assets/manifest.art.json,不得重复生成或扣费" - .to_string(), - ), - }; - } - } if let Err(error) = prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index fa17cbfcc..48ba9b4e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -540,6 +540,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| input.get("replace_existing")) .and_then(serde_json::Value::as_bool) .unwrap_or(false); + let slice_count = input + .get("sliceCount") + .or_else(|| input.get("slice_count")) + .and_then(serde_json::Value::as_u64) + .map(|value| value as usize); let requested_options = PlatformArtAssetGenerationOptions { output_path: (!output_path.trim().is_empty()).then_some(output_path), aspect_ratio, @@ -547,83 +552,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio asset_kind, asset_label, replace_existing, + slice_count, }; - let canonical_options = match agent_id { - "art-director" => Some(PlatformArtAssetGenerationOptions { - output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), - asset_label: "游戏统一视觉规范图".to_string(), - replace_existing: false, - }), - "design-foundation" - if requested_options - .output_path - .as_deref() - .is_some_and(design_foundation_ui_page_output_path_is_valid) => - { - Some(PlatformArtAssetGenerationOptions { - output_path: requested_options.output_path.clone(), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: if requested_options.asset_label.trim().is_empty() { - "游戏功能页面设计图".to_string() - } else { - requested_options.asset_label.clone() - }, - replace_existing: false, - }) - } - "design-foundation" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/ui-prototype.png".to_string()), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: "游戏横屏界面原型图".to_string(), - replace_existing: false, - }), - "art-asset-plan" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/art-spritesheet.png".to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "art-spritesheet".to_string(), - asset_label: "游戏首版核心美术素材".to_string(), - replace_existing: false, - }), - _ => None, - }; - let mut options = if let Some(canonical) = canonical_options { - let mismatch = requested_options - .output_path - .as_deref() - .is_some_and(|value| Some(value) != canonical.output_path.as_deref()) - || (!requested_options.aspect_ratio.is_empty() - && requested_options.aspect_ratio != canonical.aspect_ratio) - || (!requested_options.image_size.is_empty() - && requested_options.image_size != canonical.image_size) - || (!requested_options.asset_kind.is_empty() - && requested_options.asset_kind != canonical.asset_kind) - || (!requested_options.asset_label.is_empty() - && requested_options.asset_label != canonical.asset_label); - if mismatch { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: format!( - "图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}", - canonical.output_path.as_deref().unwrap_or("null"), - canonical.aspect_ratio, - canonical.image_size, - canonical.asset_kind, - canonical.asset_label, - ), - detail: None, - }; - } - canonical - } else { + let mut options = { let defaults = PlatformArtAssetGenerationOptions::default(); PlatformArtAssetGenerationOptions { output_path: requested_options.output_path, @@ -648,6 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio requested_options.asset_label }, replace_existing, + slice_count, } }; options.replace_existing = replace_existing; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index aec1efbc2..9cb4fda28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -242,6 +242,27 @@ pub(crate) fn render_agc_skill_pack_index() -> Result { Ok(lines.join("\n")) } +pub(crate) fn read_agc_skill_resource(resource: &str) -> Result { + let manifest = validated_skill_pack_manifest()?; + let normalized = resource.trim().trim_start_matches('/').replace('\\', "/"); + let (skill_name, relative) = normalized + .split_once('/') + .ok_or_else(|| "Skill 资源路径必须是 skill/file".to_string())?; + let entry = manifest + .skills + .iter() + .find(|entry| entry.name == skill_name) + .ok_or_else(|| "未登记的 AGC Skill 资源".to_string())?; + if !entry.files.iter().any(|file| file == relative) || !is_safe_skill_relative_path(relative) { + return Err("未登记或不安全的 AGC Skill 资源".to_string()); + } + let bundled_path = format!("{skill_name}/{relative}"); + let bytes = + bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?; + let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?; + String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".to_string()) +} + pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result { let manifest = validated_skill_pack_manifest()?; let skills_root = isolated_os_home.join(".agents").join("skills"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 3f34dc890..9bab62192 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1386,7 +1386,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。" } "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 90ffd9333..8e39c1712 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1489,14 +1489,12 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#" Genarrative Game Draft - -
还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。
+
"#; +const DEFAULT_GAME_STYLE_CSS: &str = "body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }\nmain { width: min(720px, calc(100vw - 32px)); }\n"; +const DEFAULT_GAME_SCRIPT_JS: &str = "import Phaser from 'phaser';\nimport './style.css';\n\nclass PlaceholderScene extends Phaser.Scene {\n create() { this.add.text(24, 24, '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。'); }\n}\n\nnew Phaser.Game({ type: Phaser.AUTO, width: 720, height: 420, parent: 'game', scene: PlaceholderScene });\n"; const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000"; const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"; @@ -2486,6 +2484,8 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + start_game_creator_external_mcp, + stop_game_creator_external_mcp, create_automatic_local_game_project, init_local_game_project, import_local_godot_project, diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index ab6c73245..f79c9bc39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1405,9 +1405,14 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { .position(|candidate| candidate == needle) } -/// 解析项目游戏根:项目根存在 `index.html` 时使用项目根(新布局), -/// 否则回退到旧布局的 `game/` 子目录。 +/// npm 工程只预览构建结果;单 HTML 工程保留现有入口布局。 pub(crate) fn project_game_root(root: &Path) -> PathBuf { + if root.join("package.json").is_file() || root.join("dist/index.html").is_file() { + return root.join("dist"); + } + if root.join("game/package.json").is_file() || root.join("game/dist/index.html").is_file() { + return root.join("game/dist"); + } if root.join("index.html").is_file() { root.to_path_buf() } else { @@ -1419,9 +1424,24 @@ pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result Result Result metadata, @@ -1586,6 +1643,43 @@ mod tests { use super::*; use std::fs; + #[test] + fn npm_preview_requires_build_and_prefers_bundled_assets() { + let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); + fs::create_dir_all(&base).unwrap(); + let root = tempfile::tempdir_in(base).unwrap(); + fs::write(root.path().join("package.json"), "{}").unwrap(); + fs::write(root.path().join("index.html"), "source").unwrap(); + assert!(resolve_preview_path(root.path(), "/").is_err()); + fs::create_dir_all(root.path().join("dist/assets")).unwrap(); + fs::create_dir_all(root.path().join("assets")).unwrap(); + fs::write(root.path().join("dist/index.html"), "").unwrap(); + fs::write(root.path().join("dist/assets/main.js"), "bundled").unwrap(); + fs::write(root.path().join("assets/main.js"), "source").unwrap(); + fs::write(root.path().join("assets/hero.png"), "image").unwrap(); + assert_eq!( + resolve_preview_path(root.path(), "/assets/main.js").unwrap(), + root.path() + .join("dist/assets/main.js") + .canonicalize() + .unwrap() + ); + assert!(resolve_preview_path(root.path(), "/assets/hero.png").is_err()); + fs::create_dir_all(root.path().join("game")).unwrap(); + fs::write(root.path().join("game/index.html"), "source").unwrap(); + assert!(resolve_preview_path(root.path(), "/game/index.html").is_err()); + assert!(resolve_preview_path(root.path(), "/assets/%2e%2e/index.html").is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink( + root.path().join("index.html"), + root.path().join("dist/assets/leak.html"), + ) + .unwrap(); + assert!(resolve_preview_path(root.path(), "/assets/leak.html").is_err()); + } + } + #[test] fn root_layout_serves_root_entry_and_keeps_legacy_paths_available() { let root = tempfile::tempdir().expect("create preview root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index d52959460..6b4f7f43c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -4,26 +4,7 @@ pub(crate) fn export_local_project_package_at( root: &Path, ) -> Result { validate_project_root(root)?; - ensure_project_export_package_dir(root, "game")?; - let game_index_path = resolve_local_project_path(root, "game/index.html")?; - if !game_index_path.is_file() { - return Err("导出试玩包前需要先生成 game/index.html".to_string()); - } - let game_index_metadata = checked_export_package_metadata(&game_index_path, "game/index.html")?; - if !game_index_metadata.is_file() { - return Err("导出试玩包前需要先生成 game/index.html".to_string()); - } - prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; - let game_index = fs::read_to_string(&game_index_path) - .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; - if game_index.trim().is_empty() { - return Err("导出试玩包前 game/index.html 不能为空".to_string()); - } - let lower_game_index = game_index.to_ascii_lowercase(); - if !lower_game_index.contains(" Result, String> { let mut files = Vec::new(); - collect_project_export_package_dir_files(root, "game", &mut files)?; - if resolve_local_project_path(root, "assets")?.exists() { + let game_root = crate::preview::project_game_root(root); + let built = game_root == root.join("dist") || game_root == root.join("game/dist"); + if built { + let relative = relative_project_path(root, &game_root)?; + collect_project_export_package_dir_files(root, &relative, &mut files)?; + for (name, _, _) in &mut files { + *name = format!( + "game/{}", + name.strip_prefix(&format!("{relative}/")) + .ok_or("构建产物路径非法")? + ); + } + } else { + collect_project_export_package_dir_files(root, "game", &mut files)?; + } + if !built && resolve_local_project_path(root, "assets")?.exists() { collect_project_export_package_dir_files(root, "assets", &mut files)?; } let readme_path = resolve_local_project_path(root, "exports/README.md")?; @@ -312,3 +307,42 @@ pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result } normalize_relative_path(relative_path) } + +#[cfg(test)] +mod npm_export_tests { + use super::*; + + #[test] + fn npm_package_contains_only_dist_and_publish_readme() { + let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); + fs::create_dir_all(&base).unwrap(); + let root = tempfile::tempdir_in(base).unwrap(); + for directory in ["dist/assets", "assets", "exports", "node_modules", "game"] { + fs::create_dir_all(root.path().join(directory)).unwrap(); + } + for file in [ + "package.json", + "dist/index.html", + "dist/assets/main.js", + "assets/hero.png", + "exports/README.md", + "node_modules/private.js", + "game/source.js", + ] { + fs::write(root.path().join(file), "test").unwrap(); + } + let files = collect_project_export_package_files(root.path()).unwrap(); + let names = files + .iter() + .map(|(name, _, _)| name.as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "exports/README.md", + "game/assets/main.js", + "game/index.html" + ] + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 6059702a5..c9e73140c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -9,6 +9,32 @@ static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); pub(crate) const GAME_CREATION_PROJECT_NAME_MAX_CHARS: usize = 80; +const DEFAULT_GAME_PACKAGE_JSON: &str = r#"{ + "name": "agc-game", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "phaser": "4.2.1" + }, + "devDependencies": { + "vite": "^6.2.0" + } +} +"#; + +const DEFAULT_GAME_VITE_CONFIG: &str = r#"import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + base: './', + build: { outDir: 'dist', emptyOutDir: true }, +}); +"#; + pub(crate) fn normalize_game_creation_project_name(value: &str) -> Result { let name = value.trim(); if name.is_empty() { @@ -445,6 +471,11 @@ pub(crate) fn init_local_game_project_at( let name = normalize_game_creation_project_name(name)?; prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?; + let create_npm_scaffold = !manifest_storage_exists(&root.join(".agent/manifest.json"))? + && !root.join("index.html").exists() + && !root.join("game/index.html").exists() + && !root.join("package.json").exists() + && !root.join("game/package.json").exists(); for relative in ["game", "assets", "memory", "memory/agents", "exports"] { let path = root.join(relative); ensure_game_creator_private_directory_tree(&path, "本地项目目录")?; @@ -459,6 +490,32 @@ pub(crate) fn init_local_game_project_at( "默认游戏入口", )?; } + if create_npm_scaffold { + for (relative, content, label) in [ + ( + "game/package.json", + DEFAULT_GAME_PACKAGE_JSON, + "游戏 npm 配置", + ), + ( + "game/package-lock.json", + include_str!("../../resources/agc-game-package-lock.json"), + "游戏 npm 锁文件", + ), + ( + "game/vite.config.js", + DEFAULT_GAME_VITE_CONFIG, + "游戏 Vite 配置", + ), + ("game/style.css", DEFAULT_GAME_STYLE_CSS, "游戏样式"), + ("game/game.js", DEFAULT_GAME_SCRIPT_JS, "游戏入口脚本"), + ] { + let path = root.join(relative); + if !prepare_game_creator_private_path_for_read(&path, false, label)? { + crate::write_game_creator_private_file(&path, content.as_bytes(), label)?; + } + } + } let agent_db_path = root.join(".agent/agent.db"); if !agent_db_path.exists() { @@ -1514,3 +1571,41 @@ pub(crate) fn trim_optional_string(value: Option) -> Option { mod import_tests; #[cfg(test)] mod recovery_tests; + +#[cfg(test)] +mod npm_scaffold_tests { + use super::*; + + #[test] + fn npm_scaffold_uses_package_import_and_preserves_user_changes() { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + init_local_game_project_at(root.path(), "npm-scaffold", "游戏").unwrap(); + let package: serde_json::Value = + serde_json::from_slice(&fs::read(root.path().join("game/package.json")).unwrap()) + .unwrap(); + assert_eq!(package["dependencies"]["phaser"], "4.2.1"); + assert!(fs::read_to_string(root.path().join("game/game.js")) + .unwrap() + .contains("import Phaser from 'phaser'")); + assert!(root.path().join("game/package-lock.json").is_file()); + fs::write(root.path().join("game/game.js"), "user source").unwrap(); + init_local_game_project_at(root.path(), "npm-scaffold", "游戏").unwrap(); + assert_eq!( + fs::read_to_string(root.path().join("game/game.js")).unwrap(), + "user source" + ); + } + + #[test] + fn npm_scaffold_does_not_migrate_an_existing_html_project() { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + fs::create_dir(root.path().join("game")).unwrap(); + fs::write(root.path().join("game/index.html"), "existing html").unwrap(); + init_local_game_project_at(root.path(), "existing-html", "已有游戏").unwrap(); + assert!(!root.path().join("game/package.json").exists()); + assert_eq!( + fs::read_to_string(root.path().join("game/index.html")).unwrap(), + "existing html" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index acc780b40..245149735 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -14,16 +14,12 @@ pub(crate) fn run_limited_local_command_at( return Err("项目目录必须是绝对路径".to_string()); } - let game_index_path = root.join("game/index.html"); - prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; - let html = fs::read_to_string(&game_index_path) - .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; - if !html.contains(" Result<(PathBuf, String), String> { + let game_root = crate::preview::project_game_root(root); + let index = crate::preview::resolve_preview_path(root, "/")?; + prepare_game_creator_private_path_for_read(&index, false, "游戏入口")?; + let html = fs::read_to_string(&index).map_err(|error| format!("读取游戏入口失败:{error}"))?; + let lower = html.to_ascii_lowercase(); + if !lower.contains(" Result<(), String> { + let tags = regex::Regex::new(r"(?is)<(?:script|link|img|audio|video|source)\b[^>]*>").unwrap(); + let attributes = + regex::Regex::new(r#"(?is)\s+([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#) + .unwrap(); + for tag in tags.find_iter(html) { + for attribute in attributes.captures_iter(tag.as_str()) { + let name = attribute.get(1).unwrap().as_str(); + if !name.eq_ignore_ascii_case("src") && !name.eq_ignore_ascii_case("href") { + continue; + } + let value = attribute + .get(2) + .or_else(|| attribute.get(3)) + .or_else(|| attribute.get(4)) + .unwrap() + .as_str(); + if value.is_empty() + || value.starts_with('#') + || value.starts_with("//") + || value.contains(':') + { + continue; + } + let path = value.split(['?', '#']).next().unwrap_or(value); + let path = path.strip_prefix("./").unwrap_or(path); + crate::preview::resolve_preview_path( + root, + &format!("/{}", path.trim_start_matches('/')), + ) + .map_err(|error| format!("构建入口引用不可用:{value}: {error}"))?; + } + } + Ok(()) +} + pub(crate) const PROJECT_VERIFICATION_OUTPUT_MAX_BYTES: usize = 24 * 1024; const PROJECT_VERIFICATION_PACKAGE_MAX_BYTES: u64 = 512 * 1024; const PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS: u64 = 1; @@ -840,3 +889,25 @@ pub(crate) fn enforce_project_auto_permission_policy( } Ok(()) } + +#[cfg(test)] +mod npm_build_tests { + use super::*; + + #[test] + fn built_smoke_checks_module_files_without_inline_canvas() { + let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); + fs::create_dir_all(&base).unwrap(); + let root = tempfile::tempdir_in(base).unwrap(); + fs::create_dir_all(root.path().join("dist/assets")).unwrap(); + fs::write(root.path().join("package.json"), "{}").unwrap(); + let html = r#""#; + fs::write(root.path().join("dist/index.html"), html).unwrap(); + assert!(validate_built_game_references(root.path(), html).is_err()); + fs::write(root.path().join("dist/assets/main.js"), "export {};").unwrap(); + fs::write(root.path().join("dist/assets/main.css"), "body{}").unwrap(); + assert!(validate_built_game_references(root.path(), html).is_ok()); + let lazy_html = r#"src='not-a-reference.png'"#; + assert!(validate_built_game_references(root.path(), lazy_html).is_ok()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 54f8eae14..ca5ce863e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1165,6 +1165,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, + slice_count: None, }, ) .await; @@ -4856,6 +4857,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index d9130a640..4418da177 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -10,12 +10,14 @@ import { isBackendReady, isProcessGroupAlive, preflightExistingVite, + readBackendServiceFailure, readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, stopChild, terminateChildTree, + waitForBackendReady, waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; @@ -26,6 +28,7 @@ function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { return { schemaVersion: spacetimeDataDir ? 2 : 1, database: expectedDatabase, + updatedAt: '', ...(spacetimeDataDir ? { spacetimeDataDir } : {}), services: { 'api-server': { @@ -127,6 +130,47 @@ describe('AI 游戏创作配套后端复用门禁', () => { }), ).resolves.toBe(true); }); + + test('后端服务失败时返回具体失败服务,避免外层无限等待', () => { + const state = backendState(expectedDataDir); + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 1; + state.services['bgfilter-worker'].signal = null; + + expect(readBackendServiceFailure(state)).toEqual({ + serviceName: 'bgfilter-worker', + failure: 'code=1', + }); + }); + + test('不匹配的旧状态失败记录不会阻断当前后端启动', () => { + const state = backendState(resolve('server-rs/.spacetimedb/other/data')); + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 1; + + expect(readBackendServiceFailure(state)).toBeNull(); + }); + + test('等待后端时立即传播状态文件中的服务失败', async () => { + const initialState = backendState(expectedDataDir); + initialState.updatedAt = '2026-09-04T08:00:00.000Z'; + const state = backendState(expectedDataDir); + state.updatedAt = '2026-09-04T08:00:01.000Z'; + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 98; + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + }); + let readCount = 0; + + await expect( + waitForBackendReady(child, 100, { + checkBackendReady: async () => false, + readState: () => (readCount++ === 0 ? initialState : state), + }), + ).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98'); + }); }); describe('AI 游戏创作启动子进程生命周期', () => { diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 78e074daf..507747e57 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3372,8 +3372,14 @@ }, "sliceLayout": { "type": "string", - "enum": ["grid-2x2"], - "description": "可选固定图集切片合同。省略时沿用全图 alpha 连通域自动拆分;传 grid-2x2 时服务端要求生成四个固定象限,并按左上、右上、左下、右下各持久化一个独立切片。该模式适用于需要恰好四类核心运行时素材的游戏,不会猜测等分裁切。" + "deprecated": true, + "description": "历史兼容字段,新的调用请使用 sliceCount。" + }, + "sliceCount": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "可选的目标切片数量;省略时按图像内容自动识别。" }, "screenColor": { "type": ["string", "null"], @@ -3597,15 +3603,21 @@ }, "iconImageSrcs": { "type": "array", - "description": "默认模式识别图集中全部有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。sliceLayout=grid-2x2 时固定返回左上、右上、左下、右下四个格子的切片,各格内的零散视觉细节不会被拆成额外素材。", + "description": "识别图集中有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;可通过 sliceCount 指定目标数量。", "items": { "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } }, "sliceLayout": { "type": "string", - "enum": ["grid-2x2"], - "description": "仅当请求使用固定切片合同且主图完成透明化、切片持久化后返回。调用方可将该字段与 iconImageSrcs=4 共同作为固定四类素材的来源证明。" + "deprecated": true, + "description": "历史兼容字段。" + }, + "sliceCount": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "实际生成的切片数量。" }, "sliceWarning": { "anyOf": [ diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 765f3a318..ca060acb0 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -51,9 +51,11 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 ## AGC DirectProject 与 UI workflow +- 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建,已有单 HTML/Godot 不自动迁移。 + - 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。 - DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP,并在启动时额外读取客户端扩展仓库中已启用的第三方 MCP 独立项。第三方 STDIO/HTTP 配置只写入本次隔离 `CODEX_HOME`,单项非 required,启停、重命名和内容指纹进入 app-server pool identity;完整 Plugin Runtime、hooks/apps 和单文件脚本手动指定入口仍关闭。Skill 正文与 references 由 Codex 原生按需读取;`agc_tools` 负责标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用仍由客户端绑定回合、幂等账本、请求上限和投影权威。 -- DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内可用;原生命令网络保持关闭。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`,provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。 +- DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在用户项目 cwd 与 `workspaceWrite(writableRoots=[project])` 内可用;原生命令允许联网以支持 npm 安装,npm 缓存位于项目内 `.npm-cache/`。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`,provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。 - `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。 - UI workflow 的资源桥接与 Runtime 边界以 `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` 和 AGC 实施计划的 2026-08-24 覆盖段为准;只生成图片、登记空 JSON 或进入普通图片画布都不构成 workflow 完成。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a46b980e3..79a527af8 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,13 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-08 Web 游戏 npm 与 Phaser 4 产物合同 + +新建 Web 游戏使用 npm 工程:默认 `game/package.json` 声明 Phaser 4.2.1 和 Vite 构建工具,源码使用 `import Phaser from 'phaser'`,`package-lock.json` 由 npm 维护。默认脚手架文件位于 `game/`,包含 `index.html`、`game.js`、`style.css`、`vite.config.js` 和 npm 配置/锁文件;已有根目录 npm 工程沿用原根,可按需求拆分模块并添加任意其它 npm 依赖,不设置包名白名单。客户端不手工分发 Phaser bundle,不用 import map 模拟 package 导入。 + +Agent 在包含 `package.json` 的目录执行 `npm ci`(依赖变更使用 `npm install`)和 `npm run build`;默认可从工作区根执行 `npm --prefix game ci` 与 `npm --prefix game run build`。DirectProject 保持 workspace-write 目录边界并开放网络,以支持 npm 依赖解析和安装;凭据仍由客户端代理持有,不进入项目或原生命令环境。其它执行模式维持现有权限。新游戏完成后执行 `npm run build` 并用真实浏览器试玩;依赖安装与构建失败必须反馈真实错误,不能回退成未解析裸模块导入的静态页面。 + +npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.html`,静态 smoke 校验构建入口及本地文件引用,实际可玩性由浏览器验证。预览服务与导出读取该构建目录,所有运行素材必须由构建纳入 dist;npm 预览不回退读取源码或项目素材目录,保证试玩与导出一致。源码投影包含 package、锁文件、配置和真实游戏源码,排除 `node_modules/`、`dist/`、控制文件与凭据;npm 试玩包将 dist 文件映射到 `game/` 并附带发布说明,不包含源码依赖安装目录。现有单 HTML 项目不自动迁移,Godot 项目保持原合同。旧 JSON Generator 仅接受已初始化的单 HTML 项目,npm 项目或新建请求在调用 LLM 前明确拒绝并引导使用 DirectProject,避免静态草案伪装为 npm 构建产物。本节覆盖下文仅适用于旧单 HTML 产物的 Canvas API、手写动画循环和禁止外部本地脚本要求。 + ## 2026-09-02 项目名称显示与自动提炼 - `.agent/manifest.json` 的 `name` 仍是本地项目显示名唯一事实源;项目组页行尾更多菜单提供行内重命名,保存必须走 Tauri 受控命令、项目写锁、manifest 写锁与既有 ACL/权限校验。重命名只更新 manifest,不改变项目目录、`projectId`、项目类型、任务、资源、版本或远端同步状态;保存成功后当前项目上下文、窗口标题和最近项目检查结果必须回读新 manifest 并保持一致。 @@ -238,6 +246,13 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 旧配置迁移:既有 AppData 若没有 `agentMode`,只有全局和逐 Agent 路由均为 `openai_responses` 时迁移到 `codex_app_server`;存在 `openai_chat / anthropic` 时显式保留 `provider`,避免打开项目自动恢复时把所有节点批量写成 `invalid-config`。用户确认端点支持 Responses 后,可在设置中显式切换并保留原 model/base URL/API Key。 - 验收:fake JSON-RPC fixture、三态 UI/config、配置指纹、unknown-terminal 零重放、旧两种模式回归和显式 ignored 真实 smoke 全部通过后,才可视为模式切换完成。 +### 2026-09-03 AGC 客户端能力以 MCP 暴露 + +- MCP 暴露是客户端能力层,不替换 `codex_app_server / codex_cli / provider` 或客户端对话。客户端对话入口继续驱动 Codex app-server;app-server 通过客户端随附的 stdio MCP 子进程调用审核后的客户端能力。客户端不再替 Codex 做业务语义门禁、意图判断和完成判定。 +- MCP 会话在客户端握手时绑定当前账号、项目和实例,工具参数不得携带 `projectPath`、Token、Cookie、objectKey 或内部 URL。仅暴露稳定业务白名单与 `resources/list/read`,所有文件、资源、画布、预览和 operation 副作用继续复用客户端权限、锁、计费、幂等账本、manifest/revision 与恢复机制。 +- 客户端对话继续写入现有 conversation projection;外部 Host 如需旁路保存返回文本,可显式调用 `conversation.record_codex_response`。客户端将有界、脱敏正文、SHA-256、安全摘要和状态追加到项目级 journal,UI 只展示记录,不从文本推断业务状态或触发副作用。 +- MCP 子进程只由当前客户端为绑定项目启动,并在该项目工作目录内运行;客户端回合结束或客户端退出后子进程随 Codex app-server 一并回收。账号和项目权限仍由客户端业务桥接层校验,未知副作用保持 `needs-reconciliation`,只能通过 operation 查询恢复。稳定验收覆盖客户端对话驱动的 MCP 工具调用、Skill 指导资源、跨项目/账号拒绝以及旧 Provider/Codex 回归。 + ### 2026-08-10 Supervisor 边做边聊与条件中断 - 根 Project Supervisor 的运行中消息继续进入当前 `taskId / sessionId / runId`,先持久显示“正在判断、当前任务继续”,再由独立 LLM 生成非终态语义回复并给出 `interruptCurrentProvider`。过程回复不能调用终态 `respond_to_user`,不能把制作 Run、Goal 或 task 提前完成。 @@ -1067,7 +1082,7 @@ game-project/ - 内部 owner 验证只接受 GUI / CLI 完整 16 任务 DAG 中 `agent-ready-task-scheduler` 启动的确定性直接 child、当前活跃根和完整 project/source/profile/Agent/run/parent/root/binding 身份。错误 source、delegated run、历史或终态根、非当前活跃根、跨 Agent/run 凭证均失败关闭;再次 mutation 使旧凭证失效,相同身份恢复可按当前事实确定性重验。本阶段不扩到后置 `publish-package`。`code-prototype` 与 `preview-readiness` 继续执行真实 `game.static_smoke`,`preview-playtest` 继续独立执行浏览器验收;任何 owner 文件凭证都不能替代可玩证据。 - `design-foundation` 的 2026-07-26 职责隔离继续有效:项目文件仍只允许 `memory/project.md`、`game/game_design.md` 和配置 Key 时的固定 `assets/ui-prototype.png`,禁止修改 `game/index.html`、调用 smoke / preview / process 或恢复整项目。未配置 External Editor API Key 时 `art-director` 保持只读协调;配置 Key 时它是条件 Canvas owner,必须生成并登记 `assets/art-spec.png`,成功 `canvas.asset_generate` 为本人当前 revision 形成普通验证凭证,不能被只读分类吞掉。配置 Key 时 UI 原型、透明图集、Canvas 登记和视觉门仍按既有合同执行,内部 owner 文件验证不替代图片证据。 - `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。 -- 在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 用当前调用模式的图片生成 `kind=spec` 生成 `assets/art-spec.png` 并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的稳定资源 ID 作为视觉规范参考,用同模式图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 以同一 resource ID 调用同模式图标 spritesheet 生成,产出透明 `assets/art-spritesheet.png`。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;业务请求、依赖和验收完全一致。规范图缺失、未登记或缺少稳定资源 ID 时,下游任务不得退回普通生图。图集 warning、透明像素与切片门禁保持不变。 +- 视觉 Agent 只负责指导 Codex 选择合适的图片/编辑/图集工具并提供项目上下文,不再固定图片数量、文件槽位、素材类别或 spritesheet 布局;请求可按玩法需要生成单图、多图或任意切片布局。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;权限、计费、幂等、资源登记和安全校验保持不变。 - 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation` 与 `art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheet,Runtime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。 - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。 - 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。 diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md index bc6aeeafe..cf52261c4 100644 --- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md @@ -14,6 +14,10 @@ ## 3. 已确定的产品边界 +### 3.0 客户端能力 MCP 暴露边界(2026-09-03) + +客户端仍由现有对话入口启动并驱动 Codex;MCP 只是把客户端已审核的项目、文件、资源、画布、生成和预览能力暴露给该 Codex 或其它 Host。客户端只负责账号、项目路径、权限、计费、幂等、锁和恢复等自身安全,不替 Codex 做高层意图/完成门禁。审核 Skill 的索引和正文可作为只读 MCP resource 提供,第三方扩展不得获得客户端会话凭据、内部路径或 bridge token;该能力与公网 `/api/external/v1/mcp` 保持独立。 + ### 3.1 客户端安装、运行时注入 - 扩展内容保存在 AGC 客户端的扩展仓库。 diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md index 789c00173..d8a570436 100644 --- a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md +++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md @@ -89,6 +89,10 @@ Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Rea 不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。 +### 2026-09-03 客户端对话与 MCP 能力边界 + +客户端对话仍由现有 Codex app-server 链路完成;MCP 只暴露客户端自身业务能力和审核 Skill 指导。客户端安全门禁限于账号、项目路径、权限、计费、幂等、锁、revision 与恢复,不根据 Codex 自然语言替代 Codex 决定业务动作。外部 Host 返回如需旁路归档,可使用显式记录工具,但不替代现有 conversation projection,也不触发资源、状态或完成判定。 + jsonl 每条自带 `recordedAtMs`(`unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead`。`agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。 ## 5. 记录合同 diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index 828bee880..4b543bef8 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -76,6 +76,7 @@ ### 失败生成任务归档与任务侧栏 - 用户界面的“删除失败任务”语义是归档,不物理销毁私有 generation ledger。只有平台明确失败的 `failed` 任务可归档;`reconciliation-required`、已受理、运行中和结果未知任务不得移出恢复队列。 +- 已受理 operation 在重试时若请求快照发生变化,客户端可先对原 operation 执行一次只读状态查询;仅当平台明确返回 `failed` 时才自动收口旧账本并允许新提交,排队、运行中、完成或未知状态继续保持原幂等身份并阻断替代请求。 - 归档命令校验 project、draft、generation 与 expected draft revision,先把私有 ledger 写入可重放的 `archiving/archivedAt`,再从 `draft.generations` 移除公开投影并推进一次 revision;草稿删除成功并回读后才发布 `archived`。`archiving` 以及历史上已写 `archived` 但仍残留公开记录的状态都必须在恢复阶段幂等收敛,且不依赖图片生成服务凭证。 - 失败占位和右上角任务项复用同一个归档动作,成功后两处同时消失,其它任务、图层和候选不受影响。 - 任务侧栏折叠只属于当前会话 UI 状态,不写入 draft 或 manifest。视觉和交互复用现役美术画布:右上角独立“任务列表”图标按钮、20rem 白色模糊卡、总数徽标、`排队/生成中` 与 `已完成` 双 Tab、状态圆形图标、阶段进度和时间信息;折叠后只保留图标按钮,不显示摘要卡。用户显式新建 generation 时自动展开并切回活动 Tab,普通进度更新不得推翻用户已有折叠选择。Game Agent 的失败归档作为任务行扩展保留。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 69b9c9325..bee46452e 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -62,7 +62,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 -AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 +AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 diff --git a/package-lock.json b/package-lock.json index 47cf1217e..3b119a3f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -110,6 +110,7 @@ "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", "lucide-react": "^0.546.0", + "phaser": "^4.2.1", "react": "^19.0.0", "react-arborist": "^3.16.0", "react-colorful": "^5.8.0", @@ -11570,7 +11571,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, "license": "MIT" }, "node_modules/events-universal": { @@ -17706,6 +17706,15 @@ "node": "*" } }, + "node_modules/phaser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", + "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -26309,6 +26318,7 @@ "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", "lucide-react": "^0.546.0", + "phaser": "^4.2.1", "react": "^19.0.0", "react-arborist": "^3.16.0", "react-colorful": "^5.8.0", @@ -30478,8 +30488,7 @@ "eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" }, "events-universal": { "version": "1.0.1", @@ -34500,6 +34509,14 @@ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", "dev": true }, + "phaser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", + "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", + "requires": { + "eventemitter3": "^5.0.4" + } + }, "picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 198c31aef..949fadf99 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -863,6 +863,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { reference_id, reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions, + slice_count: None, slice_layout: None, style: None, model: Some(args.model), diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d9f110905..b0663a4b1 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -8579,6 +8579,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: None, + slice_count: None, slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -8645,6 +8646,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: None, + slice_count: None, slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -8729,6 +8731,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( slice_source, request_context.external_call_deadline(), None, + None, ) .await { @@ -8891,6 +8894,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_height, icon_image_srcs, slice_layout: None, + slice_count: None, slice_warning, prompt, actual_prompt: generated.actual_prompt, @@ -19059,7 +19063,7 @@ mod tests { .checked_sub(Duration::from_millis(1)) .expect("expired deadline should be representable"); - let error = slice_editor_icon_spritesheet_all(source, Some(expired), None) + let error = slice_editor_icon_spritesheet_all(source, Some(expired), None, None) .await .err() .expect("expired CPU budget must fail before decoding"); @@ -19748,6 +19752,7 @@ mod tests { spritesheet_height: 512, icon_image_srcs: Vec::new(), slice_layout: None, + slice_count: None, slice_warning: Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, reason: "图集中未识别到可拆分的独立素材。".to_string(), diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 9e8c307aa..f7f318b86 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -251,6 +251,9 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { pub(crate) reference_id: String, pub(crate) reference_image_srcs: Option>, pub(crate) icon_descriptions: Vec, + /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) slice_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) slice_layout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -267,8 +270,7 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { pub(crate) canvas_completion: Option, } -/// Opt-in fixed atlas slicing. Existing callers remain on the default -/// connected-component path unless they explicitly request this layout. +/// Deprecated compatibility layout. New callers should use `sliceCount`。 #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] pub(crate) enum EditorIconSpritesheetSliceLayout { #[serde(rename = "grid-2x2")] @@ -313,6 +315,8 @@ pub(crate) struct EditorIconSpritesheetGenerationResponse { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_layout: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) slice_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_warning: Option, pub(crate) prompt: String, pub(crate) actual_prompt: Option, @@ -1783,6 +1787,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: payload.slice_layout, + slice_count: Some(0), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -1864,6 +1869,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: payload.slice_layout, + slice_count: Some(0), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -1961,6 +1967,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( slice_source, request_context.external_call_deadline(), payload.slice_layout, + payload.slice_count, ) .await { @@ -2053,6 +2060,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheetHeight": spritesheet_height, "iconImageSrcs": &icon_image_srcs, "sliceLayout": payload.slice_layout, + "sliceCount": payload.slice_count, "sliceWarning": &slice_warning, "warning": &generation_warning, "prompt": user_prompt.clone(), @@ -2121,6 +2129,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( icon.asset = item.asset.map(editor_asset_payload_from_record); } + let slice_count = icon_image_srcs.len(); Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { @@ -2129,6 +2138,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_height, icon_image_srcs, slice_layout: payload.slice_layout, + slice_count: Some(slice_count), slice_warning, prompt, actual_prompt: generated.actual_prompt, @@ -2323,6 +2333,7 @@ pub async fn split_editor_icon_spritesheet( processing_deadline, memory_admission, None, + None, ) .await?; let prompt = source_resource @@ -2398,6 +2409,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all( source: DownloadedImage, request_deadline: Option, slice_layout: Option, + slice_count: Option, ) -> Result { let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline); @@ -2408,6 +2420,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all( processing_deadline, memory_admission, slice_layout, + slice_count, ) .await } @@ -2446,6 +2459,7 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( processing_deadline: Instant, memory_admission: Arc, slice_layout: Option, + slice_count: Option, ) -> Result { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); @@ -2484,7 +2498,9 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( } None => prepare_generated_icon_spritesheet_all_by_connected_components( &source, - EDITOR_ICON_SPRITESHEET_MAX_SLICES, + slice_count + .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES) + .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES), EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, ), } @@ -2505,6 +2521,15 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; + if let Some(expected) = slice_count { + if expected == 0 || expected > EDITOR_ICON_SPRITESHEET_MAX_SLICES || plan.len() != expected + { + return Err(AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": format!("请求切片数量为 {expected},实际识别到 {} 个。请调整 sliceCount 或素材排布。", plan.len()), + }))); + } + } if plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ @@ -2915,6 +2940,7 @@ mod tests { source, None, Some(EditorIconSpritesheetSliceLayout::Grid2x2), + None, ) .await .expect("declared 2x2 sheet should slice"); diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 0a15d26ef..de48a2d15 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -2617,13 +2617,8 @@ mod tests { .is_some_and(|description| description.contains("同步返回 400")) ); assert_eq!( - icon_spritesheet_request["properties"]["sliceLayout"]["enum"], - json!(["grid-2x2"]) - ); - assert!( - icon_spritesheet_request["properties"]["sliceLayout"]["description"] - .as_str() - .is_some_and(|description| description.contains("固定图集切片合同")) + icon_spritesheet_request["properties"]["sliceCount"]["minimum"], + json!(1) ); let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] ["properties"]["style"]; @@ -2635,11 +2630,7 @@ mod tests { ["sliceWarning"]["anyOf"][0]["$ref"], "#/components/schemas/EditorIconSpritesheetSliceWarning" ); - assert_eq!( - parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"] - ["sliceLayout"]["enum"], - json!(["grid-2x2"]) - ); + assert!(parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]["sliceCount"].is_object()); assert_eq!( parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"] ["anyOf"][0]["$ref"], diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index fa80d9b32..722213247 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1365,6 +1365,7 @@ fn compact_external_api_generation_result(result: Value) -> Value { | "spritesheetHeight" | "iconImageSrcs" | "sliceLayout" + | "sliceCount" | "frames" | "frameCount" | "frameWidth"