diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index a468efd86..e195301c3 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -51,6 +51,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/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 f499b3268..759606477 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) @@ -4517,7 +4521,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..d5370f671 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", + ), ] } @@ -3184,7 +3199,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 +3222,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 +3717,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 +7457,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 +7494,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 +7514,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 +8398,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/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/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/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/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index f7f9f5a1c..3e543d2f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1467,14 +1467,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"; 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 ed6387a54..12a677216 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; @@ -835,3 +884,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/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index e59d90fb1..4585eee85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#" * 对每个 Component,直接完整返回其全部参数. * 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。 * 纯结构节点可以返回空数组并标为 NoProblem。 +* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致 * 面向用户的 reason 使用中文。 + "#; #[derive(Clone, Debug, Deserialize, JsonSchema)] diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts index 9e044efb8..52d0fa137 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts @@ -14,3 +14,20 @@ export function collectUiNodeIds(root: UiNode): Set { visitUiNodes(root, (node) => ids.add(node.id)); return ids; } + +export function findUiNode(root: UiNode, nodeId: NodeId): UiNode | null { + if (root.id === nodeId) return root; + for (const child of root.children) { + const found = findUiNode(child, nodeId); + if (found) return found; + } + return null; +} + +export function countUiNodeDescendants(root: UiNode): number { + let count = 0; + for (const child of root.children) { + count += 1 + countUiNodeDescendants(child); + } + return count; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx new file mode 100644 index 000000000..08e5362d3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx @@ -0,0 +1,56 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { NodeId } from '../../../features/ui-editor/types/NodeId'; + +export type UiNodeDeleteRequest = { + nodeId: NodeId; + nodeLabel: string; + descendantCount: number; +}; + +type UiNodeDeleteConfirmModalProps = { + request: UiNodeDeleteRequest | null; + onCancel: () => void; + onConfirm: (nodeId: NodeId) => void; + disabled?: boolean; +}; + +export function UiNodeDeleteConfirmModal({ + request, + onCancel, + onConfirm, + disabled = false, +}: UiNodeDeleteConfirmModalProps) { + return ( + +

确认删除节点?

+

+ 将删除「{request?.nodeLabel ?? ''}」及其 {request?.descendantCount ?? 0}{' '} + 个后代节点,是否继续? +

+
+ + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx index 5974e3ef7..14fde9869 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -1,7 +1,10 @@ -import { ChevronDown, Eye, EyeOff } from 'lucide-react'; +import { ChevronDown, Eye, EyeOff, Puzzle, Trash2 } from 'lucide-react'; import { + forwardRef, + type HTMLAttributes, type MouseEvent as ReactMouseEvent, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -9,16 +12,25 @@ import { adjustMoveIndex, type MoveHandler, type NodeRendererProps, + type RowRendererProps, Tree, type TreeApi, } from 'react-arborist'; +import { + countUiNodeDescendants, + findUiNode, +} from '../../../features/ui-editor/treeUtils'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest'; import type { UiEditorNodeFocusRequest } from '../model'; import { UiNodeContextMenu } from './UiNodeContextMenu'; +import { + UiNodeDeleteConfirmModal, + type UiNodeDeleteRequest, +} from './UiNodeDeleteConfirmModal'; type UiTreePanelProps = { root: UiNode | null; @@ -36,6 +48,38 @@ type UiTreePanelProps = { className?: string; }; +const TreeListOuter = forwardRef< + HTMLDivElement, + HTMLAttributes +>(function TreeListOuter({ style, ...props }, ref) { + return ( +
+ ); +}); + +function TreeRowContainer({ + node, + innerRef, + attrs, + children, +}: RowRendererProps) { + return ( +
event.stopPropagation()} + onClick={node.handleClick} + > + {children} +
+ ); +} + function TreeRow({ node, style, @@ -44,11 +88,17 @@ function TreeRow({ isNodeVisible, onToggleNodeVisibility, onOpenContextMenu, + onRequestDelete, + canDelete, + deleteDisabled, }: NodeRendererProps & { onSelectNode: (id: NodeId) => void; isNodeVisible: (nodeId: NodeId) => boolean; onToggleNodeVisibility: (id: NodeId) => void; onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void; + onRequestDelete: (id: NodeId) => void; + canDelete: boolean; + deleteDisabled: boolean; }) { const data = node.data; const nodeLabel = data.metadata.name || '未命名节点'; @@ -56,8 +106,8 @@ function TreeRow({ return (
{ @@ -88,23 +138,53 @@ function TreeRow({ /> ) : null} - {nodeLabel} - - {data.components.length} + + {nodeLabel} - + + + + +
); } @@ -127,11 +207,15 @@ export function UiTreePanel({ const treeApiRef = useRef | undefined>(undefined); const treeContainerRef = useRef(null); const [treeHeight, setTreeHeight] = useState(0); + const [treeWidth, setTreeWidth] = useState(0); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; y: number; } | null>(null); + const [pendingDelete, setPendingDelete] = useState< + (UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null + >(null); useEffect(() => { if (!focusRequest) return; @@ -150,6 +234,41 @@ export function UiTreePanel({ return () => resizeObserver.disconnect(); }, []); + const treeRows = useMemo(() => { + if (!root) return []; + const rows: Array<{ node: UiNode; depth: number }> = []; + const visit = (node: UiNode, depth: number) => { + rows.push({ node, depth }); + node.children.forEach((child) => visit(child, depth + 1)); + }; + visit(root, 0); + return rows; + }, [root]); + + const treeContentWidth = useMemo( + () => + treeRows.reduce((width, { node, depth }) => { + const labelWidth = Math.max( + 56, + (node.metadata.name || '未命名节点').length * 8, + ); + return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52); + }, 0), + [treeRows], + ); + + useEffect(() => { + const container = treeContainerRef.current; + if (!container) return; + const updateWidth = () => { + setTreeWidth(Math.max(treeContentWidth, container.clientWidth)); + }; + updateWidth(); + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, [treeContentWidth]); + const handleMove: MoveHandler = ({ dragIds, parentId, @@ -184,21 +303,55 @@ export function UiTreePanel({ const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []); const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null; + const requestDelete = (nodeId: NodeId) => { + if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) { + return; + } + const treeId = treeIdForNode(nodeId); + const node = findUiNode(root, nodeId); + if (!treeId || !node) return; + const descendantCount = countUiNodeDescendants(node); + if (descendantCount === 0) { + onDeleteNode(treeId, nodeId); + return; + } + setPendingDelete({ + treeId, + nodeId, + nodeLabel: node.metadata.name || '未命名节点', + descendantCount, + }); + }; + + const confirmDelete = (nodeId: NodeId) => { + if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) { + return; + } + const { treeId } = pendingDelete; + setPendingDelete(null); + onDeleteNode(treeId, nodeId); + }; + return (
event.preventDefault()} > -
+
{root && treeHeight > 0 ? ( ref={treeApiRef} data={[root]} - width="100%" + width={treeWidth > 0 ? treeWidth : '100%'} + outerElementType={TreeListOuter} height={treeHeight} rowHeight={34} indent={16} openByDefault + renderRow={TreeRowContainer} selection={selectedNodeId ?? undefined} onSelect={(nodes) => { const selected = nodes[0]?.data; @@ -224,6 +377,12 @@ export function UiTreePanel({ }} isNodeVisible={isNodePreviewVisible} onToggleNodeVisibility={onToggleNodeVisibility} + onRequestDelete={requestDelete} + canDelete={ + props.node.data.id !== root.id && + !pageRootIds.has(props.node.data.id) + } + deleteDisabled={isLocked} onOpenContextMenu={(event, nodeId) => nodeId === root.id ? undefined @@ -252,9 +411,15 @@ export function UiTreePanel({ onClose={() => setContextMenu(null)} onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)} onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)} - onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)} + onDelete={requestDelete} /> ) : null} + setPendingDelete(null)} + onConfirm={confirmDelete} + disabled={isLocked} + />
); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx new file mode 100644 index 000000000..fbda3f214 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx @@ -0,0 +1,40 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { WorkflowCompletionNotice } from './workflowCompletionNotice'; +import { workflowStepLabel } from './workflowCompletionNotice'; + +export function WorkflowCompletionModal({ + notice, + onClose, +}: { + notice: WorkflowCompletionNotice | null; + onClose: () => void; +}) { + if (!notice) return null; + const stepLabel = workflowStepLabel(notice.step); + const outcomeLabel = notice.outcome === 'success' ? '完成' : '失败'; + return ( + +

+ {stepLabel} + {outcomeLabel} +

+

+ {notice.message} +

+
+ +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 75a1efce3..c923ca875 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -1,4 +1,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, type CanvasViewport, createPanDragState, type DragState, @@ -28,8 +30,14 @@ import type { Node } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; +import { + handlePreviewZoomKeyDown, + isPreviewZoomInteractiveTarget, + previewZoomUsesMetaModifier, +} from './previewZoomKeyboard'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; +import { ZoomPercentageInput } from './ZoomPercentageInput'; export function PreviewWorkspace({ canvas, @@ -41,6 +49,8 @@ export function PreviewWorkspace({ const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 }); const handledFocusRequestIdRef = useRef(null); const panRef = useRef | null>(null); + const previewFocusedRef = useRef(false); + const previewHoveredRef = useRef(false); const [viewport, setViewportState] = useState( viewportRef.current, ); @@ -150,6 +160,34 @@ export function PreviewWorkspace({ ); }, [logicalSize, setViewport]); + const scaleViewportFromCenter = useCallback( + (nextScale: number) => { + const element = viewportElementRef.current; + const width = element?.clientWidth || canvasSize.width; + const height = element?.clientHeight || canvasSize.height; + setViewport( + scaleViewportFromScreenPoint({ + viewport: viewportRef.current, + nextScale, + screenPoint: { x: width / 2, y: height / 2 }, + }), + ); + }, + [canvasSize.height, canvasSize.width, setViewport], + ); + + const resetToActualSize = useCallback(() => { + scaleViewportFromCenter(1); + }, [scaleViewportFromCenter]); + + const zoomIn = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR); + }, [scaleViewportFromCenter]); + + const zoomOut = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR); + }, [scaleViewportFromCenter]); + useEffect(() => { const element = viewportElementRef.current; if (!element) return; @@ -224,6 +262,9 @@ export function PreviewWorkspace({ ), ); }; + const usesMetaModifier = previewZoomUsesMetaModifier( + window.navigator.platform, + ); const onKeyDown = (event: KeyboardEvent) => { if ( event.code === 'Space' && @@ -232,23 +273,21 @@ export function PreviewWorkspace({ ) { setSpaceHeld(true); } - if (!event.ctrlKey && !event.metaKey) return; - if (event.key === '0') { - event.preventDefault(); - fitToCanvas(); - } else if (event.key === '1' && logicalSize) { - event.preventDefault(); - const element = viewportElementRef.current; - const width = element?.clientWidth ?? 900; - const height = element?.clientHeight ?? 640; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale: 1, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - } + handlePreviewZoomKeyDown( + event, + { + hasZoomableViewport: logicalSize !== null, + isFocused: previewFocusedRef.current, + isHovered: previewHoveredRef.current, + usesMetaModifier, + }, + { + fit: fitToCanvas, + resetToActualSize, + zoomIn, + zoomOut, + }, + ); }; const onKeyUp = (event: KeyboardEvent) => { if (event.code === 'Space') setSpaceHeld(false); @@ -262,9 +301,12 @@ export function PreviewWorkspace({ window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onWindowBlur); }; - }, [fitToCanvas, logicalSize, setViewport]); + }, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]); const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) { + event.currentTarget.focus({ preventScroll: true }); + } if (event.button === 1 || (event.button === 0 && spaceHeld)) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); @@ -371,7 +413,27 @@ export function PreviewWorkspace({
{ + previewHoveredRef.current = true; + }} + onPointerLeave={() => { + previewHoveredRef.current = false; + }} + onFocus={() => { + previewFocusedRef.current = true; + }} + onBlur={(event) => { + if ( + !(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget) + ) { + previewFocusedRef.current = false; + } + }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} @@ -448,21 +510,20 @@ export function PreviewWorkspace({ { - const element = viewportElementRef.current; - const width = element?.clientWidth ?? canvasSize.width; - const height = element?.clientHeight ?? canvasSize.height; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - }} + onScaleFromCenter={scaleViewportFromCenter} > {(actions) => ( -
+
{ + if ( + event.target instanceof Element && + event.target.closest('button') + ) { + event.preventDefault(); + } + }} + > + + actions.zoomToDisplayScale(Number(event.target.value) / 100) + } + /> + + actions.zoomToDisplayScale(percent / 100) + } + />
+ setReturnConfirmOpen(false)} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts new file mode 100644 index 000000000..90657f5b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts @@ -0,0 +1,82 @@ +import type { NodeId } from '../../features/ui-editor/types/NodeId'; +import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; + +type DeleteResult = { ok: boolean } | undefined; + +export type UiEditorKeyboardActions = { + selectedNodeId: NodeId | null; + activeImageId: UIDesignImageId | null; + deleteNode: (nodeId: NodeId, treeId: UIDesignImageId) => DeleteResult; + historyUndo: () => boolean; + historyRedo: () => boolean; +}; + +function isEditableTarget(target: EventTarget | null) { + const element = target instanceof HTMLElement ? target : null; + return Boolean( + element?.isContentEditable || + element?.closest('input, textarea, select, [contenteditable="true"]'), + ); +} + +function isInteractiveTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean( + (target instanceof HTMLElement && target.isContentEditable) || + element?.closest( + 'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]', + ), + ); +} + +function isModalTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean(element?.closest('[role="dialog"], [aria-modal="true"]')); +} + +export function handleUiEditorKeyDown( + event: KeyboardEvent, + actions: UiEditorKeyboardActions, +) { + if (isModalTarget(event.target)) return; + + if ( + (event.key === 'Delete' || event.key === 'Backspace') && + !event.repeat && + !event.defaultPrevented && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + !isInteractiveTarget(event.target) + ) { + if (actions.selectedNodeId && actions.activeImageId) { + const result = actions.deleteNode( + actions.selectedNodeId, + actions.activeImageId, + ); + if (result?.ok) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + } + if ( + event.repeat || + event.defaultPrevented || + isEditableTarget(event.target) || + (!event.ctrlKey && !event.metaKey) + ) { + return; + } + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey; + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + (event.ctrlKey && event.key.toLowerCase() === 'y'); + if (isUndo && actions.historyUndo()) { + event.preventDefault(); + } else if (isRedo && actions.historyRedo()) { + event.preventDefault(); + } +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index b2c98484b..a580a652d 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -10,7 +10,10 @@ import { } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; -import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview'; +import { + getStageStatusOverview, + type StageStatusField, +} from '../../features/ui-editor/stageStatusOverview'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; @@ -54,6 +57,10 @@ import { prerequisiteIssuesForStep, type UiEditorPrerequisiteIssue, } from './components/WorkflowChecks'; +import { + appendWorkflowCheckPrompt, + type WorkflowCompletionNotice, +} from './components/workflowCompletionNotice'; import { type PendingResourceRemoval, removalHasDownstreamReferences, @@ -175,6 +182,8 @@ export function useUiEditorSession( initialFurthestStepIndex = 0, ) { const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE); + const editorDeleteNode = editor.deleteNode; + const editorUiTrees = editor.state.ui_trees; const replaceEditorState = editor.replaceState; const [isLoading, setIsLoading] = useState(Boolean(resourceId)); const [loadError, setLoadError] = useState(null); @@ -246,6 +255,19 @@ export function useUiEditorSession( const [hasSuggested, setHasSuggested] = useState(false); const [hasRecognized, setHasRecognized] = useState(false); const [hasBound, setHasBound] = useState(false); + const [completionNotice, setCompletionNotice] = + useState(null); + + function reportWorkflowCompletion( + step: UiEditorStepId, + outcome: WorkflowCompletionNotice['outcome'], + rawMessage: string, + setStatus: (message: string) => void, + ) { + const message = appendWorkflowCheckPrompt(rawMessage); + setStatus(message); + setCompletionNotice({ step, outcome, message }); + } useEffect(() => { setActiveStep(initialStep); @@ -437,6 +459,9 @@ export function useUiEditorSession( ); return next.size === current.size ? current : next; }); + setSelectedNodeId((current) => + current && !validNodeIds.has(current) ? null : current, + ); }, [editor.state.ui_trees]); const isNodePreviewVisible = useCallback( @@ -879,16 +904,26 @@ export function useUiEditorSession( return result; } - function deleteNode(nodeId: NodeId, treeId = activeImageId) { - if (!treeId) return; - const result = editor.deleteNode(treeId, nodeId); - if (!result.ok) { - setStatus('无法删除该节点。'); + const deleteNode = useCallback( + (nodeId: NodeId, treeId = activeImageId) => { + if (!treeId) return; + const tree = editorUiTrees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + const location = tree ? findUiNodeLocation(tree.root, nodeId) : null; + const deletedNodeIds = location ? collectUiNodeIds(location.node) : null; + const result = editorDeleteNode(treeId, nodeId); + if (!result.ok) { + setStatus('无法删除该节点。'); + return result; + } + if (selectedNodeId !== null && deletedNodeIds?.has(selectedNodeId)) { + setSelectedNodeId(null); + } return result; - } - if (selectedNodeId === nodeId) setSelectedNodeId(null); - return result; - } + }, + [activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId], + ); function selectSprite(id: SpriteAssetId) { setSelectedNodeId(null); @@ -939,6 +974,7 @@ export function useUiEditorSession( async function suggestUiDesignSemantics() { if (isSuggesting || isWorkflowBusy) return; + setCompletionNotice(null); setSuggestionStatus(null); setIsSuggesting(true); try { @@ -949,11 +985,19 @@ export function useUiEditorSession( ); editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions)); setHasSuggested(true); - setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`); + reportWorkflowCompletion( + 'reference-analysis', + 'success', + `参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`, + setSuggestionStatus, + ); }); } catch (cause) { - setSuggestionStatus( + reportWorkflowCompletion( + 'reference-analysis', + 'failure', cause instanceof Error ? cause.message : String(cause), + setSuggestionStatus, ); } finally { setIsSuggesting(false); @@ -962,6 +1006,7 @@ export function useUiEditorSession( async function recognizeUi() { if (isRecognizing || isWorkflowBusy) return; + setCompletionNotice(null); setRecognitionStatus(null); setIsRecognizing(true); try { @@ -970,14 +1015,27 @@ export function useUiEditorSession( projectPath, state: snapshot, }); - editor.replaceState(applyRecognitionResult(snapshot, result)); + const nextState = applyRecognitionResult(snapshot, result); + editor.replaceState(nextState); setHasRecognized(true); setSelectedNodeId(null); - setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`); + const overview = getStageStatusOverview( + nextState.ui_trees, + 'layout_status', + ); + reportWorkflowCompletion( + 'structure-recognition', + 'success', + `界面结构识别完成:已替换 ${result.ui_trees.length} 棵界面树,待检查 ${overview.needsAttention} 项(必须修复 ${overview.blocked} 项)`, + setRecognitionStatus, + ); }); } catch (cause) { - setRecognitionStatus( + reportWorkflowCompletion( + 'structure-recognition', + 'failure', cause instanceof Error ? cause.message : String(cause), + setRecognitionStatus, ); } finally { setIsRecognizing(false); @@ -1005,6 +1063,7 @@ export function useUiEditorSession( async function bindComponents() { if (isBinding || isWorkflowBusy) return; + setCompletionNotice(null); setBindingStatus(null); setIsBinding(true); try { @@ -1032,13 +1091,21 @@ export function useUiEditorSession( history: index < batches.length - 1 ? 'skip' : 'record', }); } - setBindingStatus( - `组件绑定完成(${batches.length}/${batches.length})。`, + reportWorkflowCompletion( + 'visual-binding', + 'success', + `视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`, + setBindingStatus, ); setHasBound(true); }); } catch (cause) { - setBindingStatus(cause instanceof Error ? cause.message : String(cause)); + reportWorkflowCompletion( + 'visual-binding', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setBindingStatus, + ); } finally { setIsBinding(false); } @@ -1303,6 +1370,7 @@ export function useUiEditorSession( isBinding, hasBound, bindingStatus, + completionNotice, bindComponents, requestStepChange, continueToNextStep: () => { @@ -1310,6 +1378,7 @@ export function useUiEditorSession( }, confirmStepChange, cancelStepChange: () => setPendingWorkflowStepChange(null), + dismissCompletionNotice: () => setCompletionNotice(null), }, dialogs: { projectPath, diff --git a/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx new file mode 100644 index 000000000..f02a71e3d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ZoomPercentageInput } from '../src/view/ui-editor/components/preview/ZoomPercentageInput'; + +describe('ZoomPercentageInput', () => { + it('edits the displayed percentage and commits on blur without fitting', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + expect((input as HTMLInputElement).value).toBe('50'); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '125' } }); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(125); + expect((input as HTMLInputElement).value).toBe('125'); + }); + + it.each([ + { value: '0', expected: 25 }, + { value: '999', expected: 200 }, + ])('clamps $value to $expected on blur', ({ value, expected }) => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value } }); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(expected); + expect((input as HTMLInputElement).value).toBe(String(expected)); + }); + + it('restores the current percentage when the draft is invalid', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(onCommit).not.toHaveBeenCalled(); + expect((input as HTMLInputElement).value).toBe('80'); + }); + + it('tracks viewport updates while not editing', () => { + const onCommit = vi.fn(); + const view = render( + , + ); + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + + view.rerender( + , + ); + + expect((input as HTMLInputElement).value).toBe('140'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx new file mode 100644 index 000000000..908086f1d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; +import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; +import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; +import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; + +class TestResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +} + +const root: UiNode = { + id: 'root', + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', + }, + metadata: { + name: '根节点', + description: '', + layout_status: 'NoProblem', + components_status: 'NoProblem', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'System', + }, + components: [], + children_display_mode: 'Stack', + children: [], +}; + +const activeImage: UIDesignImage = { + metadata: { + name: '测试界面', + description: '', + role: null, + slave_to: null, + }, + path: 'assets/page.png', + pixel_size: [1200, 800], + pixels_per_unit: 1, +}; + +function createCanvas( + overrides: Partial = {}, +): UiEditorCanvasProjection { + return { + isLocked: false, + activeImage, + activeImageId: 'page-1', + previewUrls: { 'page-1': 'data:image/png;base64,' }, + images: { 'page-1': activeImage }, + sprites: {}, + fontFaces: {}, + tree: { src_ui_design: 'page-1', root }, + selectedNode: null, + selectedNodeId: null, + keepChildrenUnchanged: false, + hiddenNodeIds: new Set(), + focusRequest: null, + status: null, + isNodePreviewVisible: vi.fn(() => true), + toggleNodePreviewVisibility: vi.fn(), + selectExclusiveChild: vi.fn(), + selectNode: vi.fn(), + clearNodeSelection: vi.fn(), + updateNodeTransform: vi.fn(), + insertNode: vi.fn(), + insertNodeAfter: vi.fn(), + deleteNode: vi.fn(), + openClearDialog: vi.fn(), + ...overrides, + }; +} + +function renderedScale(container: HTMLElement) { + const transform = ( + container.querySelector('.genarrative-image-canvas__world') as HTMLElement + ).style.transform; + const scale = /scale\(([^)]+)\)/.exec(transform)?.[1]; + if (!scale) throw new Error(`无法从 ${transform} 读取缩放值`); + return Number(scale); +} + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('PreviewWorkspace quick zoom', () => { + it('uses the toolbar zoom step while the preview is hovered', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const before = renderedScale(rendered.container); + + fireEvent.pointerEnter(preview); + const wasNotCancelled = fireEvent.keyDown(window, { + key: '=', + code: 'Equal', + ctrlKey: true, + cancelable: true, + }); + + expect(wasNotCancelled).toBe(false); + expect(renderedScale(rendered.container)).toBeCloseTo(before * 1.16); + }); + + it('requires hover or focus and ignores interactive targets', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const zoomButton = screen.getByRole('button', { name: '放大画布' }); + const initial = renderedScale(rendered.container); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerEnter(preview); + expect( + fireEvent.keyDown(zoomButton, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerLeave(preview); + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '-', + code: 'Minus', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBeCloseTo(initial * 0.86); + }); + + it('keeps actual-size and fit shortcuts within the preview scope', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const fitted = renderedScale(rendered.container); + + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '1', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(1); + + fireEvent.keyDown(window, { + key: '0', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(fitted); + }); + + it('leaves browser zoom untouched when the preview has no content', () => { + render( + , + ); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts new file mode 100644 index 000000000..5ef72b7db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + handlePreviewZoomKeyDown, + type PreviewZoomKeyboardActions, + type PreviewZoomKeyboardContext, + previewZoomUsesMetaModifier, +} from '../src/view/ui-editor/components/preview/previewZoomKeyboard'; + +function createActions(): PreviewZoomKeyboardActions { + return { + fit: vi.fn(), + resetToActualSize: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }; +} + +function dispatchShortcut({ + actions = createActions(), + context = {}, + event, + target, +}: { + actions?: PreviewZoomKeyboardActions; + context?: Partial; + event: KeyboardEventInit; + target?: HTMLElement; +}) { + const resolvedTarget = target ?? document.createElement('div'); + document.body.append(resolvedTarget); + const handler = vi.fn((keyboardEvent: KeyboardEvent) => + handlePreviewZoomKeyDown( + keyboardEvent, + { + hasZoomableViewport: true, + isFocused: false, + isHovered: true, + usesMetaModifier: false, + ...context, + }, + actions, + ), + ); + resolvedTarget.addEventListener('keydown', handler); + fireEvent.keyDown(resolvedTarget, event); + const keyboardEvent = handler.mock.calls[0]?.[0]; + return { actions, handler, keyboardEvent }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('preview zoom keyboard shortcuts', () => { + it.each([ + { key: '+', code: 'Equal' }, + { key: '=', code: 'Equal' }, + { key: '+', code: 'NumpadAdd' }, + ])('zooms in for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true, repeat: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it.each([ + { key: '-', code: 'Minus' }, + { key: '-', code: 'NumpadSubtract' }, + ])('zooms out for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomOut).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it('does not treat underscore as zoom out', () => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { + key: '_', + code: 'Minus', + ctrlKey: true, + shiftKey: true, + cancelable: true, + }, + }); + + expect(actions.zoomOut).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it.each([ + { key: '0', action: 'fit' as const }, + { key: '1', action: 'resetToActualSize' as const }, + ])('keeps the existing $key shortcut', ({ key, action }) => { + const { actions } = dispatchShortcut({ + event: { key, ctrlKey: true, cancelable: true }, + }); + + expect(actions[action]).toHaveBeenCalledTimes(1); + }); + + it('uses Cmd on Apple platforms and Ctrl elsewhere', () => { + expect(previewZoomUsesMetaModifier('MacIntel')).toBe(true); + expect(previewZoomUsesMetaModifier('iPad')).toBe(true); + expect(previewZoomUsesMetaModifier('Win32')).toBe(false); + + const appleActions = createActions(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).not.toHaveBeenCalled(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', metaKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each([ + { isHovered: false, isFocused: false, hasZoomableViewport: true }, + { isHovered: true, isFocused: false, hasZoomableViewport: false }, + ])('leaves inactive preview shortcuts to the host (%o)', (context) => { + const { actions, keyboardEvent } = dispatchShortcut({ + context, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('works while the preview is focused without being hovered', () => { + const { actions } = dispatchShortcut({ + context: { isFocused: true, isHovered: false }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each(['input', 'button', 'a'])('ignores interactive %s targets', (tag) => { + const target = document.createElement(tag); + if (target instanceof HTMLAnchorElement) target.href = '#preview'; + const { actions, keyboardEvent } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('does not override an event already handled by another control', () => { + const target = document.createElement('div'); + target.addEventListener('keydown', (event) => event.preventDefault(), { + once: true, + }); + const { actions } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts new file mode 100644 index 000000000..f21c284db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { handleUiEditorKeyDown } from '../src/view/ui-editor/uiEditorKeyboardShortcuts'; + +describe('ui editor keyboard shortcuts', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it.each([ + { key: 'z', ctrlKey: true }, + { key: 'z', ctrlKey: true, shiftKey: true }, + { key: 'y', ctrlKey: true }, + ])('does not change history from inside a modal (%o)', (shortcut) => { + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + const button = document.createElement('button'); + dialog.append(button); + document.body.append(dialog); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, shortcut); + + expect(historyUndo).not.toHaveBeenCalled(); + expect(historyRedo).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }); + + it('keeps undo available from a non-modal button', () => { + const button = document.createElement('button'); + document.body.append(button); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, { key: 'z', ctrlKey: true }); + + expect(historyUndo).toHaveBeenCalledTimes(1); + window.removeEventListener('keydown', listener); + }); + + it.each(['Delete', 'Backspace'])( + 'does not delete a node when the zoom slider has focus (%s)', + (key) => { + const slider = document.createElement('input'); + slider.type = 'range'; + document.body.append(slider); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(slider, { key }); + + expect(deleteNode).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }, + ); + + it('keeps node deletion available after zoom button focus is released', () => { + const zoomControls = document.createElement('div'); + const zoomIn = document.createElement('button'); + zoomControls.append(zoomIn); + document.body.append(zoomControls); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.click(zoomIn); + fireEvent.keyDown(window, { key: 'Delete' }); + + expect(deleteNode).toHaveBeenCalledWith('node-1', 'page-1'); + window.removeEventListener('keydown', listener); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 6c21bc416..2c4206167 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -473,6 +473,75 @@ describe('UiEditorPage', () => { expect(result.current.canvas.hiddenNodeIds.size).toBe(0); }); + it('clears selection when deleting a node removes the selected descendant', async () => { + const { result } = await renderLoadedSession(stateWithPages(['page'])); + const rootId = 'page-root'; + let parentId: string | undefined; + let childId: string | undefined; + act(() => { + parentId = result.current.input.insertNode(rootId, 'page')?.value; + childId = result.current.input.insertNode(parentId!, 'page')?.value; + result.current.input.selectNode(childId!); + }); + + act(() => result.current.input.deleteNode(parentId!, 'page')); + + expect(result.current.canvas.selectedNodeId).toBeNull(); + expect(result.current.history.canUndo).toBe(true); + }); + + it('deletes the selected node from the page with Delete', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-keyboard', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + fireEvent.keyDown(window, { key: 'Delete' }); + + await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); + }); + + it('does not delete a selected node when Delete originates inside a dialog', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-dialog', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + fireEvent.keyDown(dialog, { key: 'Delete' }); + + expect(screen.queryAllByText('page-child').length).toBeGreaterThan(0); + dialog.remove(); + }); + it('keeps Inspector status highlighting separate from node navigation', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts new file mode 100644 index 000000000..196077400 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + countUiNodeDescendants, + findUiNode, +} from '../src/features/ui-editor/treeUtils'; +import type { Node } from '../src/features/ui-editor/types/Node'; + +function node(id: string, children: Node[] = []): Node { + return { + id, + layout: {} as Node['layout'], + metadata: {} as Node['metadata'], + components: [], + children_display_mode: 'Stack', + children, + }; +} + +describe('ui tree utilities', () => { + it('finds a nested node and counts all descendants', () => { + const nested = node('nested', [node('leaf')]); + const root = node('root', [node('page'), nested]); + + expect(findUiNode(root, 'nested')).toBe(nested); + expect(findUiNode(root, 'missing')).toBeNull(); + expect(countUiNodeDescendants(nested)).toBe(1); + expect(countUiNodeDescendants(root)).toBe(3); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts new file mode 100644 index 000000000..01016c4ae --- /dev/null +++ b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { + appendWorkflowCheckPrompt, + workflowStepLabel, +} from '../src/view/ui-editor/components/workflowCompletionNotice'; + +describe('workflow completion notice helpers', () => { + it('appends the review prompt to a terminal status', () => { + expect(appendWorkflowCheckPrompt('已应用 3 条建议。')).toBe( + '已应用 3 条建议,请检查。', + ); + }); + + it('maps every workflow step to a user-facing label', () => { + expect(workflowStepLabel('reference-analysis')).toBe('分析参考图'); + expect(workflowStepLabel('structure-recognition')).toBe('识别界面结构'); + expect(workflowStepLabel('visual-binding')).toBe('绑定视觉素材'); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index dac81e41c..729822e03 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1598,6 +1598,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。 - 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。钱包明细每次展开都重新读取充值中心 BFF,打开期间实时总额变化时继续补读;图片画板的生成扣费或退款完成后同时刷新总额与充值中心拆分。充值中心读请求必须使用 revision 门禁,支付创建、到账确认等权威响应写入时使旧读失效,避免旧响应覆盖新的每日免费 / 不限时明细。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。 - 2026-07-17 追加:主站、图片画板与 AI 游戏创作独立 App 的泥点账单统一复用 `packages/shared/src/components/PlatformProfileWalletLedgerModal`。共享组件只依赖 `ProfileWalletLedgerResponse`,承接来源 label、金额正负号、UTC 日期、余额兜底和 loading / empty / error 展示;`/api/profile/wallet-ledger` 请求、鉴权、打开状态与重试生命周期继续由各宿主持有,不把账户事实或后端副作用下沉到共享 UI。 +- 2026-09-07 追加:资产扣费在既有钱包流水 metadata 中记录服务端确定的 `assetKind`,`GET /api/profile/wallet-ledger` 只把白名单类型映射为可选用户文案 `reason`,不暴露原始 metadata、资源 ID、任务 ID 或未知内部枚举。共享账单组件优先展示非空 `reason`;历史、未知和空 metadata 继续按 `sourceType` 回退为“资产操作消耗”,不得由客户端猜测业务类型。 - 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。 - 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 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 aeb73210c..b6171314e 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 并保持一致。 @@ -58,6 +66,10 @@ Runtime 确认卡与普通聊天确认卡必须共用“信息区 + 固定操作 ## 2026-08-19 UI Editor 节点右键菜单 +## 2026-09-05 UI Editor 节点树快捷删除 + +左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,眼睛与删除按钮组成固定宽度的右侧动作列;整棵树保留横向滚动,深层节点的缩进、完整名称和组件数随树内容一起滚动,不使用省略号截断。沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。 + ## 2026-08-20 UI Editor 最终预览互斥子节点 最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点首次加载且尚未发生可见性操作时,必须默认且仅显示第一个直接子节点;用户手动隐藏全部直接子节点后必须保持全部隐藏,不得再次回退显示第一个子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。 diff --git a/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md new file mode 100644 index 000000000..5cdc52832 --- /dev/null +++ b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md @@ -0,0 +1,40 @@ +# UI 编辑器工作流完成通知弹窗 + +## 目标 + +UI 编辑器的“分析参考图”“识别界面结构”“绑定视觉素材”三个工作流动作在每次运行结束后,用独立的阻塞通知弹窗明确反馈结果,避免仅依赖卡片内一行状态文本而被忽略。 + +## 交互约定 + +- 三个动作的每次运行在终态(成功或失败)时自动弹出一次通知。 +- 弹窗打开期间遮挡并阻塞工作台底层交互;关闭后恢复当前步骤,不自动切换步骤、不自动重跑。 +- 使用现有 `ThemedModal` 的普通关闭行为(遮罩、Esc 和关闭按钮均可关闭)。 +- 弹窗仅承载通知,不提供“继续”“重试”或其他业务操作。 +- 关闭弹窗后,工作流卡片继续显示同一条结果状态;重新运行产生新的终态时再次通知。 +- 绑定动作包含多个批次时,只在最终批次结束后通知一次。 + +## 文案 + +弹窗标题由步骤名和结果态组成,例如“识别界面结构完成”或“绑定视觉素材失败”。正文复用卡片状态文本,并逐条扩展结果信息,统一以“请检查”收尾。 + +成功状态的基线文案: + +- 分析参考图:保留已应用的语义建议数量;若现有状态可可靠取得问题/待确认数量,则一并展示。 +- 识别界面结构:保留替换的界面树数量,并展示识别结果中的待检查/必须修复数量(若可取得)。 +- 绑定视觉素材:保留现有 `B/B` 批次计数,改为用户可读的绑定结果。 + +失败状态保留实际错误文本,仅在弹窗标题中补充步骤和失败上下文,正文同样以“请检查”收尾。 + +## 实现边界 + +- 新增独立的工作流通知弹窗组件文件,组件只负责展示和关闭,不包含工作流领域规则或后端副作用。 +- 在 UI 编辑器页面/会话投影中维护临时通知状态,并在三个异步动作的成功与失败终态写入。 +- 不新增后端字段或公开契约;数量只能使用当前前端已有且可靠的数据。 + +## 验收 + +1. 三个动作成功和失败终态各弹出一次通知;绑定批次只弹最终一次。 +2. 弹窗打开时底层工作台不可操作,且无继续/重试等业务按钮。 +3. 弹窗可通过标准关闭方式退出;关闭后卡片状态仍可见。 +4. 每条成功文案保留原有数量信息并增加可用的检查数量,所有文案包含“请检查”。 +5. 重新运行后新的终态会再次弹出通知。 diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index f3912e000..86d7e3cd9 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -48,13 +48,19 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - 桌面端页面工具栏提供撤销和重做按钮。 - 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。 -- `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。 +- `input`、`textarea`、`select`、`contenteditable` 等文本编辑控件交给浏览器原生行为,不拦截文本撤销;按钮/链接等非文本控件仍允许编辑器撤销/重做快捷键生效。 +- UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。 +- `Delete` / `Backspace` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。 +- `Delete` / `Backspace` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时交给对话框处理,不删除对话框背后的节点。 +- `Cmd/Ctrl+Z`、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时暂停编辑器级撤销/重做,由对话框或浏览器原生行为处理。 - 无可撤销或重做记录时按钮禁用,并提供可访问名称。 ## 脏状态与选择 撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。 +删除节点后,若当前选中节点是被删除节点或其任意后代,页面将选区清空为 `null`;删除成功不强制抢占焦点。该选择清理属于 UI 临时状态,不写入撤销历史。 + ## 验收标准 1. 单次字段编辑可撤销和重做。 @@ -67,3 +73,4 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 9. no-op、锁定、校验失败和不存在目标不进入历史。 10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 +11. `Delete` / `Backspace` 仅在非交互控件聚焦且存在可删除选中节点时生效;删除整棵子树、可撤销、长按只处理一次,并清理被删除子树内的无效选区。 diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md new file mode 100644 index 000000000..198d7788a --- /dev/null +++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md @@ -0,0 +1,26 @@ +# 预览画布缩放滑杆 + +## 交付目标 + +将 UI 编辑器预览区现有的缩放按钮替换为桌面端可拖动滑杆,同时保留快速缩小、放大和适配画布操作。 + +## 交互约定 + +- 滑杆选择范围为 25%–200%,步进 5%;快捷加减继续使用核心视口的现有缩放边界。 +- 滑杆位于预览区右下角固定工具栏;不提供移动端或窄窗口 fallback。 +- 保留 `−` 与 `+` 按钮。点击轨道可跳转,拖动滑块实时更新画布和百分比。 +- 缩放以视口中心为中心,继续支持 Ctrl/Cmd + 滚轮缩放。 +- 滑杆支持方向键、Home、End;百分比文本可点击编辑并在失焦时限制到有效范围。 +- 百分比文本使用数字输入框呈现;输入提交后同步视口缩放,适配画布保持独立按钮,点击百分比不会触发适配。 +- 缩放滑杆获得焦点时,Delete/Backspace 不触发 UI 节点快捷删除;缩放按钮鼠标点击不改变快捷键焦点。 +- Windows/Linux 使用 `Ctrl`,macOS 使用 `Cmd`;`+`、`=` 与 `NumpadAdd` 放大,`-` 与 `NumpadSubtract` 缩小,允许按键重复时连续缩放。 +- 快捷键直接复用现有 `+`/`−` 按钮动作:以视口中心为锚点,按 `×1.16`/`×0.86` 改变缩放。 +- 鼠标悬停在预览画布上,或预览画布已获得键盘焦点时,快捷键生效。预览画布需提供可聚焦语义与无障碍名称。 +- 事件来自输入框、文本编辑区、按钮、链接、缩放工具栏或其他可操作控件时不拦截;不向 iframe 子文档注入监听。 +- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()` 与 `stopPropagation()`,防止浏览器页面同时缩放。 +- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。 +- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。 + +## 验收 + +桌面端可通过按钮、轨道点击、拖动和键盘改变缩放;百分比与画布同步,边界不会越界。组件级测试覆盖平台修饰键、主键盘与小键盘变体、按键重复、悬停/焦点作用域、可编辑与可操作目标放行、无内容放行、浏览器默认行为拦截和现有 `0`/`1` 回归;另运行前端定向类型检查、编码检查和 `git diff --check`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8cae77d2d..6e8f7cb5a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -251,6 +251,7 @@ npm run check:server-rs-ddd 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。 12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,客户端仅以 GET-only 项目对账判定权威结果,不复用既存 metadata 作 exact compare-and-return。 +13. 资产操作扣费必须在既有 `profile_wallet_ledger.metadata_json` 中记录服务端确定的 `assetKind`;外部生成任务继续同时记录 `externalGenerationJobId` 与 `externalGenerationClaimAttempt`。公开 `GET /api/profile/wallet-ledger` 不返回原始 metadata、资源 ID 或任务 ID,只为 `asset_operation_consume` 下发可选的用户可见 `reason`:图片、图标图集、美术规范、UI 设计和发布素材等图片生成统一显示“生成美术素材”,图片修改显示“编辑美术素材”,UI 素材提取显示“提取美术素材”,角色动画、视频、音效和背景音乐分别显示对应生成原因。未知、空值和未携带新 metadata 的历史流水不猜测业务含义,不下发 `reason`,共享前端继续回退到来源类型文案“资产操作消耗”。该展示投影不得改变定价、扣费顺序、幂等 ledger、失败退款或余额结算语义。 ## 外部服务与资产 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/packages/image-canvas-core/src/model.ts b/packages/image-canvas-core/src/model.ts index 3deeac6f8..b6b6c3430 100644 --- a/packages/image-canvas-core/src/model.ts +++ b/packages/image-canvas-core/src/model.ts @@ -4,6 +4,8 @@ export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const MIN_SCALE = 0.025; export const MAX_SCALE = 3.2; +export const CANVAS_ZOOM_IN_FACTOR = 1.16; +export const CANVAS_ZOOM_OUT_FACTOR = 0.86; export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const FIT_VIEW_PADDING = 10; diff --git a/packages/image-canvas-react/src/ZoomControls.tsx b/packages/image-canvas-react/src/ZoomControls.tsx index e9f853c26..53e88ccec 100644 --- a/packages/image-canvas-react/src/ZoomControls.tsx +++ b/packages/image-canvas-react/src/ZoomControls.tsx @@ -1,4 +1,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, canvasDisplayScaleToViewportScale, type CanvasViewport, formatCanvasDisplayScalePercent, @@ -27,8 +29,8 @@ export function ZoomControls({ const actions: CanvasZoomActions = { displayPercent: formatCanvasDisplayScalePercent(viewport.scale), fit: onFit, - zoomIn: () => onScaleFromCenter(viewport.scale * 1.16), - zoomOut: () => onScaleFromCenter(viewport.scale * 0.86), + zoomIn: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_IN_FACTOR), + zoomOut: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_OUT_FACTOR), zoomToDisplayScale: (displayScale) => onScaleFromCenter(canvasDisplayScaleToViewportScale(displayScale)), }; diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx index a2d15aea6..2c3369106 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx @@ -131,4 +131,45 @@ test('builds wallet ledger presentation with stable source fallbacks', () => { }, ], }); + + for (const reason of [ + '生成美术素材', + '编辑美术素材', + '提取美术素材', + '生成角色动画', + '生成视频素材', + '生成音效素材', + '生成背景音乐', + ]) { + expect( + buildWalletLedgerPresentation( + { + entries: [ + buildLedgerEntry({ + amountDelta: -12, + sourceType: 'asset_operation_consume', + reason, + }), + ], + }, + 12, + ).entries[0]?.sourceLabel, + `unexpected source label for ${reason}`, + ).toBe(reason); + } + + expect( + buildWalletLedgerPresentation( + { + entries: [ + buildLedgerEntry({ + amountDelta: -12, + sourceType: 'asset_operation_consume', + reason: ' ', + }), + ], + }, + 12, + ).entries[0]?.sourceLabel, + ).toBe('资产操作消耗'); }); diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index 40bd70e41..a18adce79 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -103,7 +103,8 @@ export function buildWalletLedgerPresentation( createdAtLabel: formatWalletLedgerDate(entry.createdAt), id: entry.id, isIncome: entry.amountDelta > 0, - sourceLabel: getWalletLedgerSourceLabel(entry.sourceType), + sourceLabel: + entry.reason?.trim() || getWalletLedgerSourceLabel(entry.sourceType), })), }; } diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 1fa899e4d..eee7978ed 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -84,6 +84,7 @@ export type ProfileWalletLedgerEntry = { | 'puzzle_author_incentive_claim' | 'daily_task_reward'; createdAt: string; + reason?: string; }; export type ProfileWalletLedgerResponse = { diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index e0ce18d6d..f9eb99573 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -202,6 +202,7 @@ where points_cost, &billing_plan.current.consume_ledger_id, wallet_metadata_json( + asset_kind, billing_plan.external_generation_job_id.as_deref(), billing_plan.external_generation_claim_attempt, ), @@ -642,9 +643,10 @@ fn resolve_asset_operation_points_cost(configured_points_cost: u64) -> u64 { } #[cfg(test)] -fn current_wallet_metadata_json() -> String { +fn current_wallet_metadata_json(asset_kind: &str) -> String { let billing_context = current_external_generation_billing_context(); wallet_metadata_json( + asset_kind, billing_context .as_ref() .map(|context| context.job_id.as_str()), @@ -655,21 +657,21 @@ fn current_wallet_metadata_json() -> String { } fn wallet_metadata_json( + asset_kind: &str, external_generation_job_id: Option<&str>, external_generation_claim_attempt: Option, ) -> String { - let Some(external_generation_job_id) = external_generation_job_id + let mut metadata = json!({ + "assetKind": asset_kind.trim(), + }); + if let Some(external_generation_job_id) = external_generation_job_id .map(str::trim) .filter(|value| !value.is_empty()) - else { - return module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(); - }; - - json!({ - "externalGenerationJobId": external_generation_job_id, - "externalGenerationClaimAttempt": external_generation_claim_attempt, - }) - .to_string() + { + metadata["externalGenerationJobId"] = json!(external_generation_job_id); + metadata["externalGenerationClaimAttempt"] = json!(external_generation_claim_attempt); + } + metadata.to_string() } pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { @@ -971,13 +973,20 @@ mod tests { async { ( resolve_asset_operation_points_cost(99), - current_wallet_metadata_json(), + current_wallet_metadata_json("editor_generated_image"), ) }, ) .await; assert_eq!(points_cost, 37); + assert_eq!( + serde_json::from_str::(&metadata_json) + .expect("worker metadata should be valid JSON") + .get("assetKind") + .and_then(serde_json::Value::as_str), + Some("editor_generated_image") + ); assert_eq!( serde_json::from_str::(&metadata_json) .expect("worker metadata should be valid JSON") @@ -994,8 +1003,11 @@ mod tests { ); assert_eq!(resolve_asset_operation_points_cost(99), 99); assert_eq!( - current_wallet_metadata_json(), - module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON + serde_json::from_str::(¤t_wallet_metadata_json( + "editor_image_edit" + )) + .expect("ordinary metadata should be valid JSON"), + json!({"assetKind": "editor_image_edit"}) ); } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index b7413f45a..db27bb20f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2064,15 +2064,46 @@ fn build_redeem_profile_reward_code_response( fn build_profile_wallet_ledger_entry_response( record: module_runtime::RuntimeProfileWalletLedgerEntryRecord, ) -> ProfileWalletLedgerEntryResponse { + let reason = profile_wallet_ledger_reason(record.source_type, &record.metadata_json); ProfileWalletLedgerEntryResponse { id: record.wallet_ledger_id, amount_delta: record.amount_delta, balance_after: record.balance_after, source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(), created_at: record.created_at, + reason, } } +fn profile_wallet_ledger_reason( + source_type: RuntimeProfileWalletLedgerSourceType, + metadata_json: &str, +) -> Option { + if source_type != RuntimeProfileWalletLedgerSourceType::AssetOperationConsume { + return None; + } + let metadata = serde_json::from_str::(metadata_json).ok()?; + let asset_kind = metadata.get("assetKind")?.as_str()?.trim(); + let reason = match asset_kind { + "editor_scene_image" + | "editor_character_image" + | "editor_spec_image" + | "editor_quick_edit_image" + | "editor_ui_design_image" + | "editor_publication_material" + | "editor_generated_image" + | "editor_icon_spritesheet" => "生成美术素材", + "editor_image_edit" => "编辑美术素材", + "editor_ui_design_asset_extraction" => "提取美术素材", + "editor_character_animation" => "生成角色动画", + "editor_video" => "生成视频素材", + "editor_sound_effect" => "生成音效素材", + "editor_background_music" => "生成背景音乐", + _ => return None, + }; + Some(reason.to_string()) +} + fn build_profile_task_center_response( record: RuntimeProfileTaskCenterRecord, ) -> ProfileTaskCenterResponse { @@ -2433,7 +2464,8 @@ mod tests { format_profile_wallet_ledger_source_type, is_wechat_profile_recharge_order_terminal_for_confirmation, map_runtime_profile_client_error, normalize_admin_invite_code_metadata, - parse_admin_profile_code_time_field, should_notify_virtual_payment_goods_delivery, + parse_admin_profile_code_time_field, profile_wallet_ledger_reason, + should_notify_virtual_payment_goods_delivery, }; #[test] @@ -2501,7 +2533,7 @@ mod tests { use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; - use serde_json::Value; + use serde_json::{Value, json}; use spacetime_client::SpacetimeClientError; use std::time::Duration; use time::OffsetDateTime; @@ -2619,6 +2651,60 @@ mod tests { ); } + #[test] + fn profile_wallet_ledger_reason_only_exposes_known_asset_operations() { + for (asset_kind, expected) in [ + ("editor_scene_image", "生成美术素材"), + ("editor_character_image", "生成美术素材"), + ("editor_spec_image", "生成美术素材"), + ("editor_quick_edit_image", "生成美术素材"), + ("editor_ui_design_image", "生成美术素材"), + ("editor_publication_material", "生成美术素材"), + ("editor_generated_image", "生成美术素材"), + ("editor_icon_spritesheet", "生成美术素材"), + ("editor_image_edit", "编辑美术素材"), + ("editor_ui_design_asset_extraction", "提取美术素材"), + ("editor_character_animation", "生成角色动画"), + ("editor_video", "生成视频素材"), + ("editor_sound_effect", "生成音效素材"), + ("editor_background_music", "生成背景音乐"), + ] { + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, + &json!({"assetKind": asset_kind}).to_string(), + ) + .as_deref(), + Some(expected), + "unexpected reason for {asset_kind}", + ); + } + + for metadata in [ + "{}", + "not-json", + r#"{"assetKind":""}"#, + r#"{"assetKind":" "}"#, + r#"{"assetKind":"unknown_internal_operation"}"#, + r#"{"assetKind":123}"#, + ] { + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, + metadata, + ), + None, + ); + } + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, + r#"{"assetKind":"editor_generated_image"}"#, + ), + None, + ); + } + #[tokio::test] async fn profile_dashboard_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index d46843ff3..704a5d444 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -225,6 +225,8 @@ pub struct ProfileWalletLedgerEntryResponse { pub balance_after: u64, pub source_type: String, pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1430,6 +1432,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD .to_string(), created_at: "2026-04-22T09:59:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-2".to_string(), @@ -1437,6 +1440,7 @@ mod tests { balance_after: 80, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(), created_at: "2026-04-22T10:00:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-3".to_string(), @@ -1445,6 +1449,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD .to_string(), created_at: "2026-04-22T10:01:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-4".to_string(), @@ -1453,6 +1458,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD .to_string(), created_at: "2026-04-22T10:02:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-5".to_string(), @@ -1460,6 +1466,7 @@ mod tests { balance_after: 200, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(), created_at: "2026-04-22T10:03:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-6".to_string(), @@ -1468,6 +1475,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME .to_string(), created_at: "2026-04-22T10:04:00Z".to_string(), + reason: Some("生成美术素材".to_string()), }, ProfileWalletLedgerEntryResponse { id: "ledger-7".to_string(), @@ -1476,6 +1484,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND .to_string(), created_at: "2026-04-22T10:05:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-8".to_string(), @@ -1484,6 +1493,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM .to_string(), created_at: "2026-04-22T10:06:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-9".to_string(), @@ -1491,6 +1501,7 @@ mod tests { balance_after: 212, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD.to_string(), created_at: "2026-04-22T10:07:00Z".to_string(), + reason: None, }, ], }) @@ -1522,6 +1533,8 @@ mod tests { payload["entries"][5]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME) ); + assert_eq!(payload["entries"][5]["reason"], json!("生成美术素材")); + assert!(payload["entries"][0].get("reason").is_none()); assert_eq!( payload["entries"][6]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)