diff --git a/.env.example b/.env.example index f11a17f8f..e7dbc59ac 100644 --- a/.env.example +++ b/.env.example @@ -235,6 +235,10 @@ VITE_DEBUG_MODE="" # This is read by api-server and exposed through /api/runtime/frontend-config. GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false" +# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。 +# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。 +GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev" + # Optional: official VikingDB credentials for regenerating build-tag similarities # with the Python embedding script. The script auto-loads `.env.local` and uses # the fixed `bge-large-zh` embedding model. diff --git a/.gitattributes b/.gitattributes index 1cf14fb9f..572a7e25f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,3 +23,6 @@ *.meta text *.anim text *.controller text + +# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。 +packages/shared/src/contracts/generated/** linguist-generated=true diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 5088f80b6..c38208380 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -221,8 +221,7 @@ jobs: - name: Run AI game creator shell agent-run smoke run: npm run check:native-shells:agc-rust-smoke - # AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate - # 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。 + # AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。 ai-game-creator-shell-rust-crates: name: AI game creator shell Rust crates runs-on: genarrative-ci @@ -263,13 +262,15 @@ jobs: # `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试 # 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红 # (见 #327 / PR #316 run 1950)。 - # 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: + # Cocos 插件也使用独立且未提交的锁文件,一并预热。 + # 这些 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: # 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内, # 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本 # 解析,不再触碰 registry index。 for manifest_path in \ server-rs/crates/agent-runtime-core/Cargo.toml \ - server-rs/crates/agent-runtime-orchestration/Cargo.toml; do + server-rs/crates/agent-runtime-orchestration/Cargo.toml \ + plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do for attempt in $(seq 1 5); do if cargo fetch \ --target x86_64-unknown-linux-gnu \ @@ -284,6 +285,23 @@ jobs: done done + - name: Prepare Unity plugin Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'Unity plugin Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + - name: Run AI game creator shell shared crate gates run: npm run check:native-shells:agc-rust-crates diff --git a/.gitignore b/.gitignore index 34e0fbde7..f66e67ded 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,10 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md /plugins/agc-cocos-editor/native/payload/ +/plugins/agc-unity-editor/dotnet/**/bin/ +/plugins/agc-unity-editor/dotnet/**/obj/ +/plugins/agc-unity-editor/dotnet/publish/ +/plugins/agc-unity-editor/dotnet/native-build/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json diff --git a/.prettierrc.json b/.prettierrc.json index 7d2081a95..c4f9a8e5d 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,14 @@ { "singleQuote": true, "semi": true, - "trailingComma": "all" + "trailingComma": "all", + "overrides": [ + { + "files": "packages/shared/src/contracts/generated/**/*.ts", + "options": { + "printWidth": 1000, + "singleQuote": false + } + } + ] } diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 1f53fd340..aa35d2d33 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -32,6 +32,8 @@ import type { AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminProjectSnapshotListQuery, + AdminProjectSnapshotListResponse, AdminRechargeOrderListQuery, AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse, @@ -198,6 +200,92 @@ export function listAdminAccounts(token: string) { return request('/admin/api/accounts', { token }); } +export function listAdminProjectSnapshots( + token: string, + query: AdminProjectSnapshotListQuery = {}, + signal?: AbortSignal, +) { + const params = new URLSearchParams(); + if (query.cursor) params.set('cursor', query.cursor); + params.set('limit', String(query.limit ?? 20)); + return request( + `/admin/api/project-snapshots?${params.toString()}`, + { token, signal }, + ); +} + +export async function downloadAdminProjectSnapshot( + token: string, + userId: string, + projectId: string, + signal?: AbortSignal, +) { + const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`; + const response = await fetch(buildRequestUrl(path), { + headers: { + Authorization: `Bearer ${token.trim()}`, + Accept: 'application/zip', + [API_RESPONSE_ENVELOPE_HEADER]: 'v1', + }, + signal, + }); + if (!response.ok) { + const responseText = await response.text(); + throw buildAdminApiError( + response, + parseJsonResponse(responseText), + responseText, + ); + } + const contentType = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase(); + if (contentType !== 'application/zip') { + await response.body?.cancel(); + throw new AdminApiError({ + message: '下载失败:服务端未返回 ZIP 工程文件', + status: response.status, + code: 'INVALID_PROJECT_ARCHIVE_RESPONSE', + }); + } + return { + blob: await response.blob(), + filename: projectArchiveFilename( + response.headers.get('content-disposition'), + ), + }; +} + +function projectArchiveFilename(contentDisposition: string | null): string { + const extended = contentDisposition?.match( + /(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i, + ); + const ordinary = contentDisposition?.match( + /(?:^|;)\s*filename=(?:"((?:[^"\\]|\\.)*)"|([^;]+))/i, + ); + let filename = + ordinary?.[1]?.replace(/\\(.)/g, '$1') ?? ordinary?.[2]?.trim() ?? ''; + if (extended?.[1]) { + try { + filename = decodeURIComponent(extended[1].trim()); + } catch { + // 非法扩展编码继续使用普通文件名。 + } + } + const safeName = Array.from(filename, (character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127 ? '_' : character; + }) + .join('') + .replace(/[<>:"/\\|?*]/g, '_') + .trim() + .replace(/[. ]+$/, ''); + if (!safeName || safeName.length > 240) return 'project.zip'; + return /\.zip$/i.test(safeName) ? safeName : `${safeName}.zip`; +} + export function createAdminAccount( token: string, payload: AdminCreateAccountRequest, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 1973d77a5..452ad30f3 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -96,6 +96,27 @@ export interface AdminMeResponse { admin: AdminSessionPayload; } +export interface AdminProjectSnapshotEntry { + userId: string; + projectId: string; + projectName: string | null; + syncRevision: number; + syncedAtMs: number; + fileCount: number; + totalBytes: number; + status: 'ready' | 'partial' | 'unverified'; +} + +export interface AdminProjectSnapshotListQuery { + cursor?: string | null; + limit?: number; +} + +export interface AdminProjectSnapshotListResponse { + items: AdminProjectSnapshotEntry[]; + nextCursor: string | null; +} + export interface AdminErrorReportEntry { batchId: string; eventCount: number; diff --git a/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts new file mode 100644 index 000000000..bee81d5f9 --- /dev/null +++ b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts @@ -0,0 +1,128 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +import { + downloadAdminProjectSnapshot, + listAdminProjectSnapshots, +} from './adminApiClient'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('项目列表携带分页与后台授权,解析标准响应', async () => { + const payload = { items: [], nextCursor: 'next' }; + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: payload })), + ); + vi.stubGlobal('fetch', fetchMock); + const controller = new AbortController(); + expect( + await listAdminProjectSnapshots( + 'admin-token', + { cursor: 'user/a+项目', limit: 20 }, + controller.signal, + ), + ).toEqual(payload); + expect(fetchMock).toHaveBeenCalledWith( + '/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), + signal: controller.signal, + }), + ); +}); + +test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('PK\u0003\u0004', { + headers: { + 'content-type': 'application/zip', + 'content-disposition': + "attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip", + }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + const controller = new AbortController(); + const archive = await downloadAdminProjectSnapshot( + 'admin-token', + 'user/a', + 'project/b', + controller.signal, + ); + expect(archive.filename).toBe('三消-r2.zip'); + expect(archive.blob.type).toBe('application/zip'); + expect(fetchMock).toHaveBeenCalledWith( + '/admin/api/project-snapshots/user%2Fa/project%2Fb/download', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + Accept: 'application/zip', + }), + signal: controller.signal, + }), + ); +}); + +test.each([ + ['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'], + ['attachment; filename="../secret.zip"', '.._secret.zip'], + ["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'], + [null, 'project.zip'], +])('ZIP 附件名兼容安全回退 %s', async (header, expected) => { + const headers: Record = { 'content-type': 'application/zip' }; + // Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。 + if (header) + headers['content-disposition'] = header.replace('工程', 'project'); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('PK', { headers })), + ); + expect( + (await downloadAdminProjectSnapshot('token', 'user', 'project')).filename, + ).toBe(expected.replace('工程', 'project')); +}); + +test.each([401, 403, 409, 500])( + '下载 HTTP %s 保留后台错误,不返回 ZIP', + async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + ok: false, + error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' }, + }), + { + status, + headers: { 'content-type': 'application/json' }, + }, + ), + ), + ); + await expect( + downloadAdminProjectSnapshot('token', 'user', 'project'), + ).rejects.toMatchObject({ + status, + code: 'SNAPSHOT_FAILURE', + message: '工程尚未同步完成', + }); + }, +); + +test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('{"ok":false}', { + headers: { 'content-type': 'application/json' }, + }), + ), + ); + await expect( + downloadAdminProjectSnapshot('token', 'user', 'project'), + ).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' }); +}); diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index afb62b22f..6fac5b208 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -31,6 +31,7 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage'; +import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage'; import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage'; import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage'; import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage'; @@ -308,6 +309,12 @@ export function AdminApp() { {activeRouteId === 'accounts' ? ( ) : null} + {activeRouteId === 'project-snapshots' ? ( + + ) : null} ); } diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 86f23f140..97554b25e 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -4,6 +4,7 @@ import { Bug, Coins, Database, + FolderArchive, GitBranch, Images, LayoutDashboard, @@ -49,6 +50,7 @@ const routeIcons = { 'editor-generation-pricing': Coins, 'editor-showcase': Star, 'editor-assets': Images, + 'project-snapshots': FolderArchive, accounts: Users, 'agc-models': ListChecks, } satisfies Record; diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index 34486f7b4..2e1a0e20a 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -122,3 +122,28 @@ test('零权限 member 不回落到 Dashboard', () => { expect(routes).toEqual([]); expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull(); }); + +test('项目工程入口对 owner 与已授权 member 开放且可分配权限', () => { + const route = { + id: 'project-snapshots', + label: '项目工程', + hash: '#project-snapshots', + }; + expect(adminRoutes.filter((item) => !item.ownerOnly)).toContainEqual(route); + expect(resolveAdminRoute('#project-snapshots')).toBe('project-snapshots'); + expect( + getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }), + ).toContainEqual(route); + expect( + getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['project-snapshots'], + }), + ).toEqual([route]); + expect( + getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['tracking'], + }), + ).not.toContainEqual(route); +}); diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 1110bb05d..10503df72 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -16,6 +16,7 @@ export type AdminRouteId = | 'editor-generation-pricing' | 'editor-showcase' | 'editor-assets' + | 'project-snapshots' | 'agc-models' | 'accounts'; @@ -54,6 +55,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, + { id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, ]; diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx index a48273fae..ac4bb8e80 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx @@ -154,6 +154,27 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async () ); }); +test('灰度发布页可选择模板库并默认启用零比例灰度', async () => { + const user = userEvent.setup(); + render( + , + ); + await screen.findByRole('button', { name: 'editor.new-toolbar' }); + await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']); + expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( + 'agc:template-library', + ); + expect( + (screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value, + ).toBe('template-library'); + expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe( + true, + ); + expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe( + '0', + ); +}); + test('灰度发布页保存时转换数组和百分比', async () => { const user = userEvent.setup(); vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({ diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx index 00d6d742f..814f2ac34 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -27,9 +27,17 @@ interface GateTargetOption { const GATE_PREFIX_LABELS: Record = { 'image-editor': '画布', + agc: '客户端', }; const FIXED_GATE_TARGETS: GateTargetOption[] = [ + { + prefix: 'agc', + suffix: 'template-library', + key: 'agc:template-library', + label: '模板库', + description: '客户端模板库灰度', + }, { prefix: 'image-editor', suffix: 'agent-sidebar', @@ -180,7 +188,7 @@ export function AdminGrayReleaseConfigPage({ setSelectedGateKey(''); setGatePrefix(option.prefix); setGateKey(option.key); - setEnabled(false); + setEnabled(option.key === 'agc:template-library'); setRolloutPercent('0'); setAllowUserIds(''); setAllowUserTags(''); diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx new file mode 100644 index 000000000..30e4ba2ae --- /dev/null +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -0,0 +1,288 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import { + AdminApiError, + downloadAdminProjectSnapshot, + listAdminProjectSnapshots, +} from '../api/adminApiClient'; +import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage'; + +vi.mock('../api/adminApiClient', async () => ({ + ...(await vi.importActual( + '../api/adminApiClient', + )), + downloadAdminProjectSnapshot: vi.fn(), + listAdminProjectSnapshots: vi.fn(), +})); + +const entry: AdminProjectSnapshotEntry = { + userId: 'user-1', + projectId: 'project-1', + projectName: '三消工程', + syncRevision: 3, + syncedAtMs: 1_700_000_000_000, + fileCount: 12, + totalBytes: 2048, + status: 'ready', +}; + +beforeEach(() => { + vi.mocked(listAdminProjectSnapshots) + .mockReset() + .mockResolvedValue({ items: [entry], nextCursor: null }); + vi.mocked(downloadAdminProjectSnapshot).mockReset(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +test('按项目展示完整性并限制未完成工程下载', async () => { + vi.mocked(listAdminProjectSnapshots).mockResolvedValue({ + items: [ + entry, + { + ...entry, + projectId: 'partial-project', + projectName: '未完成工程', + status: 'partial', + }, + { + ...entry, + projectId: 'legacy-project', + projectName: null, + status: 'unverified', + }, + ], + nextCursor: null, + }); + render(); + const completeRow = (await screen.findByText('三消工程')).closest('tr')!; + expect(within(completeRow).getByText('2 KiB')).toBeTruthy(); + expect( + within(completeRow) + .getByRole('button', { name: '下载完整工程' }) + .hasAttribute('disabled'), + ).toBe(false); + expect( + screen.getByRole('button', { name: '同步未完成' }).hasAttribute('disabled'), + ).toBe(true); + expect(screen.getByText('完整性未知')).toBeTruthy(); + expect( + screen + .getByRole('button', { name: '下载已存文件' }) + .hasAttribute('disabled'), + ).toBe(false); +}); + +test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], + nextCursor: null, + }) + .mockRejectedValueOnce(new Error('远端清单读取失败')) + .mockResolvedValueOnce({ items: [], nextCursor: null }); + render(); + fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + await screen.findByText('第二工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 2, + 'token', + { cursor: 'page-2', limit: 20 }, + expect.any(AbortSignal), + ); + expect(screen.getByText('三消工程')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByRole('alert'); + expect(screen.getByText('第二工程')).toBeTruthy(); + expect(screen.queryByText('暂无已上传项目')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('暂无已上传项目'); + expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith( + 'token', + { cursor: null, limit: 20 }, + expect.any(AbortSignal), + ); +}); + +test('下载使用返回的中文文件名,随后释放对象 URL', async () => { + const createObjectURL = vi.fn(() => 'blob:archive'); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + 'URL', + class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }, + ); + let savedFilename = ''; + let savedHref = ''; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement, + ) { + savedFilename = this.download; + savedHref = this.href; + }); + const blob = new Blob(['PK'], { type: 'application/zip' }); + vi.mocked(downloadAdminProjectSnapshot).mockResolvedValue({ + blob, + filename: '三消工程-r3.zip', + }); + render(); + const button = await screen.findByRole('button', { name: '下载完整工程' }); + vi.useFakeTimers(); + await act(async () => { + fireEvent.click(button); + }); + expect(savedFilename).toBe('三消工程-r3.zip'); + expect(savedHref).toBe('blob:archive'); + expect(createObjectURL).toHaveBeenCalledWith(blob); + expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith( + 'token', + 'user-1', + 'project-1', + expect.any(AbortSignal), + ); + act(() => vi.advanceTimersByTime(1000)); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive'); +}); + +test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => { + vi.mocked(downloadAdminProjectSnapshot).mockImplementation( + (_token, _user, _project, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')), + ); + }), + ); + const view = render( + , + ); + fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); + fireEvent.click(await screen.findByRole('button', { name: '取消下载' })); + expect( + vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted, + ).toBe(true); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {})); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + const signal = vi.mocked(listAdminProjectSnapshots).mock.calls.at(-1)?.[2]; + view.unmount(); + expect(signal?.aborted).toBe(true); +}); + +test('下载登录失效走现有会话处理,403 错误保留页面', async () => { + const onUnauthorized = vi.fn(); + vi.mocked(downloadAdminProjectSnapshot) + .mockRejectedValueOnce( + new AdminApiError({ status: 403, message: '无项目工程权限' }), + ) + .mockRejectedValueOnce( + new AdminApiError({ status: 401, message: '已过期' }), + ); + render( + , + ); + fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); + expect(await screen.findByText('无项目工程权限')).toBeTruthy(); + expect(onUnauthorized).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '下载完整工程' })); + await waitFor(() => + expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), + ); +}); + +test('首次列表失败显示错误而非空项目,401 失效回到会话处理', async () => { + const onUnauthorized = vi.fn(); + vi.mocked(listAdminProjectSnapshots) + .mockRejectedValueOnce(new Error('清单存储不可用')) + .mockRejectedValueOnce( + new AdminApiError({ status: 401, message: '已过期' }), + ); + render( + , + ); + await screen.findByText('清单存储不可用'); + expect(screen.queryByText('暂无已上传项目')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await waitFor(() => + expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), + ); +}); + +test('更换登录令牌丢弃旧列表和晚返回请求', async () => { + let finishOldRequest!: (value: { + items: AdminProjectSnapshotEntry[]; + nextCursor: null; + }) => void; + vi.mocked(listAdminProjectSnapshots) + .mockReturnValueOnce( + new Promise((resolve) => { + finishOldRequest = resolve; + }), + ) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '新账号工程' }], + nextCursor: null, + }); + const onUnauthorized = vi.fn(); + const view = render( + , + ); + const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2]; + view.rerender( + , + ); + await screen.findByText('新账号工程'); + expect(oldSignal?.aborted).toBe(true); + await act(async () => { + finishOldRequest({ items: [entry], nextCursor: null }); + }); + expect(screen.queryByText('三消工程')).toBeNull(); + expect(screen.getByText('新账号工程')).toBeTruthy(); +}); + +test('卸载后完成的下载不会创建浏览器文件', async () => { + let finishDownload!: (value: { blob: Blob; filename: string }) => void; + vi.mocked(downloadAdminProjectSnapshot).mockReturnValue( + new Promise((resolve) => { + finishDownload = resolve; + }), + ); + const click = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}); + const view = render( + , + ); + fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); + const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]; + view.unmount(); + expect(signal?.aborted).toBe(true); + await act(async () => { + finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' }); + }); + expect(click).not.toHaveBeenCalled(); +}); diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx new file mode 100644 index 000000000..489956117 --- /dev/null +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -0,0 +1,270 @@ +import { Download, RefreshCcw, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + downloadAdminProjectSnapshot, + listAdminProjectSnapshots, +} from '../api/adminApiClient'; +import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import { handlePageError } from './pageUtils'; + +interface AdminProjectSnapshotsPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const snapshotStatuses = { + ready: { + label: '已同步', + className: 'admin-status-ok', + action: '下载完整工程', + }, + partial: { + label: '同步未完成', + className: 'admin-status-pending', + action: '同步未完成', + }, + unverified: { + label: '完整性未知', + className: 'admin-status-pending', + action: '下载已存文件', + }, +}; + +export function AdminProjectSnapshotsPage({ + token, + onUnauthorized, +}: AdminProjectSnapshotsPageProps) { + const [items, setItems] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [downloadingKey, setDownloadingKey] = useState(null); + const listController = useRef(null); + const downloadController = useRef(null); + + const loadPage = useCallback( + async (cursor: string | null = null) => { + listController.current?.abort(); + const controller = new AbortController(); + listController.current = controller; + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminProjectSnapshots( + token, + { cursor, limit: 20 }, + controller.signal, + ); + if (controller.signal.aborted) return; + setItems((current) => { + if (!cursor) return response.items; + const entries = new Map( + current.map((entry) => [snapshotKey(entry), entry]), + ); + response.items.forEach((entry) => + entries.set(snapshotKey(entry), entry), + ); + return [...entries.values()]; + }); + setNextCursor(response.nextCursor); + setHasLoaded(true); + } catch (error: unknown) { + if (!controller.signal.aborted) + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + if (listController.current === controller) { + listController.current = null; + setIsLoading(false); + } + } + }, + [token, onUnauthorized], + ); + + useEffect(() => { + setItems([]); + setNextCursor(null); + setHasLoaded(false); + setDownloadingKey(null); + void loadPage(); + return () => { + listController.current?.abort(); + listController.current = null; + downloadController.current?.abort(); + downloadController.current = null; + }; + }, [loadPage]); + + async function downloadProject(entry: AdminProjectSnapshotEntry) { + if (downloadController.current || entry.status === 'partial') return; + const controller = new AbortController(); + downloadController.current = controller; + setDownloadingKey(snapshotKey(entry)); + setErrorMessage(''); + try { + const archive = await downloadAdminProjectSnapshot( + token, + entry.userId, + entry.projectId, + controller.signal, + ); + if (controller.signal.aborted) return; + const objectUrl = URL.createObjectURL(archive.blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = archive.filename; + document.body.append(link); + try { + link.click(); + } finally { + link.remove(); + // 给浏览器时间接管下载,随后释放临时 URL。 + setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + } + } catch (error: unknown) { + if (!controller.signal.aborted) + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + if (downloadController.current === controller) { + downloadController.current = null; + setDownloadingKey(null); + } + } + } + + function cancelDownload() { + downloadController.current?.abort(); + downloadController.current = null; + setDownloadingKey(null); + } + + return ( +
+
+

项目工程

+ +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+
+ + + + + + + + + + + + + + {items.map((entry) => { + const status = snapshotStatuses[entry.status]; + const isDownloading = downloadingKey === snapshotKey(entry); + return ( + + + + + + + + + + ); + })} + +
项目用户 ID同步时间文件数体积完整性操作
+ {entry.projectName || entry.projectId} + {entry.projectId} + {entry.userId} + + {new Date(entry.syncedAtMs).toLocaleString('zh-CN', { + hour12: false, + })} + 版本 {entry.syncRevision} + + + {entry.fileCount.toLocaleString('zh-CN')} + {formatBytes(entry.totalBytes)} + + {status.label} + + + {isDownloading ? ( + + ) : ( + + )} +
+
+ {hasLoaded && items.length === 0 && !errorMessage ? ( +

暂无已上传项目

+ ) : null} + {nextCursor ? ( +
+ +
+ ) : null} +
+
+ ); +} + +function snapshotKey(entry: AdminProjectSnapshotEntry) { + return `${entry.userId}/${entry.projectId}`; +} + +function formatBytes(bytes: number) { + const units = ['B', 'KiB', 'MiB', 'GiB']; + const unit = Math.min( + Math.floor(Math.log2(Math.max(1, bytes)) / 10), + units.length - 1, + ); + return `${(bytes / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} ${units[unit]}`; +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 61c02e4d6..127ea095a 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1452,6 +1452,112 @@ button:disabled { min-width: 1180px; } +.admin-project-snapshot-table { + min-width: 0; + table-layout: fixed; +} + +.admin-project-snapshot-table th, +.admin-project-snapshot-table td { + overflow-wrap: anywhere; +} + +.admin-project-snapshot-table th:first-child { + width: 20%; +} + +.admin-project-snapshot-table th:nth-child(2) { + width: 14%; +} + +.admin-project-snapshot-table th:nth-child(3) { + width: 18%; +} + +.admin-project-snapshot-table th:nth-child(4) { + width: 7%; +} + +.admin-project-snapshot-table th:nth-child(5) { + width: 9%; +} + +.admin-project-snapshot-table th:nth-child(6) { + width: 12%; +} + +.admin-project-snapshot-table th:last-child { + width: 20%; +} + +@media (max-width: 1200px) { + .admin-project-snapshot-table, + .admin-project-snapshot-table tbody { + display: block; + } + + .admin-project-snapshot-table thead { + display: none; + } + + .admin-project-snapshot-table tr { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px 16px; + border-bottom: 1px solid #eaded2; + padding: 18px 0; + } + + .admin-project-snapshot-table tr:first-child { + padding-top: 0; + } + + .admin-project-snapshot-table tr:last-child { + border-bottom: 0; + padding-bottom: 0; + } + + .admin-project-snapshot-table td { + display: flex; + min-width: 0; + align-items: baseline; + gap: 8px; + border: 0; + padding: 0; + font-size: 14px; + } + + .admin-project-snapshot-table td::before { + flex-shrink: 0; + color: #8f7868; + font-size: 12px; + content: attr(data-label); + } + + .admin-project-snapshot-table td:first-child, + .admin-project-snapshot-table td:nth-child(2), + .admin-project-snapshot-table td:nth-child(3), + .admin-project-snapshot-table td:nth-child(6), + .admin-project-snapshot-table td:last-child { + grid-column: 1 / -1; + } + + .admin-project-snapshot-table td:first-child { + display: block; + font-size: 16px; + } + + .admin-project-snapshot-table td:first-child::before, + .admin-project-snapshot-table td:last-child::before { + display: none; + } + + .admin-project-snapshot-table td:last-child button { + width: 100%; + justify-content: center; + } +} + .admin-recharge-table { min-width: 1080px; table-layout: fixed; diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs index c6b20f6dd..ef18c3780 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs @@ -676,11 +676,11 @@ async function runSelfTest() { designFoundationAssetCall?.arguments?.input?.outputPath === 'assets/ui-prototype.png' && designFoundationAssetCall.arguments.input.aspectRatio === '16:9' && - designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' && + designFoundationAssetCall.arguments.input.assetKind === 'ui-design' && artAssetPlanAssetCall?.arguments?.input?.outputPath === 'assets/art-spritesheet.png' && artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' && - artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet', + artAssetPlanAssetCall.arguments.input.assetKind === 'icon-spritesheet', 'self-test-visual-assets-invalid', ); diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 426991561..b1f317674 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -70,7 +70,7 @@ const requiredFormalArtifactSpecs = [ { path: 'game/balance.json', kind: 'json' }, { path: 'assets/manifest.art.json', kind: 'json' }, { path: 'assets/manifest.audio.json', kind: 'json' }, - { path: 'game/index.html', kind: 'game-entry' }, + { path: 'game/index.html', kind: 'file' }, { path: 'exports/README.md', kind: 'file' }, ]; const editorImageArtifactSpecs = [ diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index b1fc307f5..3917f6f9f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -68,7 +68,7 @@ export function resolveReleaseContext(args = [], env = process.env) { ); return Object.freeze({ target, - channel: resolveReleaseChannel(env, target), + channel: resolveReleaseChannel(env), bundleRoot: path.join( appRoot, 'src-tauri', @@ -87,14 +87,14 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; -/** - * 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点, - * 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。 - */ -const releaseChannels = { - 'dev-win': 'windows', - 'dev-mac': 'darwin', -}; +const reservedChannelNames = new Set([ + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', +]); /** * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 @@ -105,6 +105,7 @@ export const agcReleasePathPatterns = [ 'packages/', 'server-rs/crates/', 'plugins/agc-cocos-editor/', + 'plugins/agc-unity-editor/', 'apps/desktop-shell/src-tauri/icons/', 'package.json', 'package-lock.json', @@ -161,39 +162,36 @@ export function resolveReleasePlatform(target = defaultTarget()) { throw new Error(`不支持的发布目标:${target}`); } -export function resolveReleaseChannel( - env = process.env, - target = defaultTarget(), -) { - const platform = resolveReleasePlatform(target); - const requested = env.AGC_UPDATE_CHANNEL?.trim(); - if (requested) { - const channelPlatform = releaseChannels[requested]; - if (!channelPlatform) { - throw new Error( - `未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`, - ); - } - if (channelPlatform !== platform) { - throw new Error( - `渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`, - ); - } - return requested; - } - const defaultChannel = Object.entries(releaseChannels).find( - ([, channelPlatform]) => channelPlatform === platform, - )?.[0]; - if (!defaultChannel) { +export function resolveReleaseChannel(env = process.env) { + const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'; + if ( + !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || + channel.endsWith('-') || + reservedChannelNames.has(channel) || + /-(win|mac)$/u.test(channel) + ) { throw new Error( - `目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`, + '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', ); } - return defaultChannel; + return channel; } -export function updateManifestUrl(channel = resolveReleaseChannel()) { - return `${ossBaseUrl()}/${channel}/latest.json`; +/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */ +export function resolveReleasePartition( + channel = resolveReleaseChannel(), + target = defaultTarget(), +) { + channel = resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }); + validateReleaseTarget(target); + return `${channel}-${resolveReleasePlatform(target) === 'windows' ? 'win' : 'mac'}`; +} + +export function updateManifestUrl( + channel = resolveReleaseChannel(), + target = defaultTarget(), +) { + return `${ossBaseUrl()}/${resolveReleasePartition(channel, target)}/latest.json`; } /** @@ -244,8 +242,8 @@ async function readManifestVersion(manifestUrl, label) { } /** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */ -async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { - return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); +async function readRemoteChannelManifest(channel, target) { + return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单'); } /** @@ -257,14 +255,17 @@ async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { */ export async function resolvePreviousReleaseCommit( channel = resolveReleaseChannel(), - { override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {}, + { + override = process.env.AGC_UPDATE_PREVIOUS_COMMIT, + target = defaultTarget(), + } = {}, ) { const explicit = override?.trim(); if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { return explicit; } try { - const manifest = await readRemoteChannelManifest(channel); + const manifest = await readRemoteChannelManifest(channel, target); const commit = typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; @@ -286,12 +287,14 @@ export async function resolvePreviousReleaseCommit( */ export async function resolveRemoteHighWaterVersion( channel = resolveReleaseChannel(), + target = defaultTarget(), ) { const channelVersion = await readManifestVersion( - updateManifestUrl(channel), + updateManifestUrl(channel, target), 'OSS 渠道清单', ); - if (channel !== 'dev-win') return channelVersion; + if (channel !== 'dev' || resolveReleasePlatform(target) !== 'windows') + return channelVersion; const legacyVersion = await readManifestVersion( legacyBridgeManifestUrl(), 'OSS 迁移指针', @@ -309,9 +312,9 @@ function replaceVersionLine(source, version, pattern, label) { } export async function prepareReleaseVersion(context = resolveReleaseContext()) { - const { channel } = context; + const { channel, target } = context; const localVersion = parseVersion(readPackageJson().version, '本地版本'); - const remoteVersion = await resolveRemoteHighWaterVersion(channel); + const remoteVersion = await resolveRemoteHighWaterVersion(channel, target); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const nextVersion = requestedVersion ? parseVersion(requestedVersion, '指定版本') @@ -400,24 +403,27 @@ export function buildTauriBuildArguments( } /** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */ -export function createChannelConfig(channel = resolveReleaseChannel()) { +export function createChannelConfig( + channel = resolveReleaseChannel(), + target = defaultTarget(), +) { return { plugins: { updater: { - endpoints: [updateManifestUrl(channel)], + endpoints: [updateManifestUrl(channel, target)], }, }, }; } -function writeChannelConfigFile(channel) { +function writeChannelConfigFile(channel, target) { const configPath = path.join( os.tmpdir(), - `agc-tauri-channel-${channel}.json`, + `agc-tauri-channel-${channel}-${target}.json`, ); fs.writeFileSync( configPath, - `${JSON.stringify(createChannelConfig(channel), null, 2)}\n`, + `${JSON.stringify(createChannelConfig(channel, target), null, 2)}\n`, ); return configPath; } @@ -434,8 +440,8 @@ export function runTauriBuild( throw new Error('构建参数与发布上下文目标不一致'); } const tauriArguments = buildTauriBuildArguments(args, context.target); - const { channel } = context; - const configPath = writeChannelConfigFile(channel); + const { channel, target } = context; + const configPath = writeChannelConfigFile(channel, target); console.log( `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, ); @@ -500,6 +506,41 @@ export function selectReleaseArtifact(files, target = defaultTarget()) { ); } +export function selectFirstInstallArtifact( + files, + { target, version, artifact }, +) { + validateReleaseTarget(target); + let selected; + if (target.includes('windows')) { + selected = artifact; + if (!selected?.endsWith('.exe')) { + throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包'); + } + } else { + // Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。 + const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64'; + const suffix = `_${version}_${architecture}.dmg`; + const candidates = files.filter((file) => + path.basename(file).endsWith(suffix), + ); + if (candidates.length !== 1) { + throw new Error( + `首装 DMG 必须唯一匹配本次版本 ${version} 和架构 ${architecture},找到 ${candidates.length} 个`, + ); + } + selected = candidates[0]; + } + if ( + !fs.existsSync(selected) || + !fs.statSync(selected).isFile() || + fs.statSync(selected).size === 0 + ) { + throw new Error(`首装包不存在或为空:${selected}`); + } + return selected; +} + function readUpdaterSignature(artifactPath) { const signaturePath = `${artifactPath}.sig`; if (!fs.existsSync(signaturePath)) { @@ -516,27 +557,36 @@ export function createUpdateManifest( artifactPath, { target = defaultTarget(), - channel = resolveReleaseChannel(process.env, target), + channel = resolveReleaseChannel(), publishedAt = new Date().toISOString(), notes = readReleaseNotes(), commit = readHeadCommit(), + downloadArtifact, } = {}, ) { validateReleaseTarget(target); - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); + const partition = resolveReleasePartition(channel, target); const signature = readUpdaterSignature(artifactPath); const version = readPackageJson().version; + const firstInstallArtifact = selectFirstInstallArtifact( + downloadArtifact ? [downloadArtifact] : [], + { target, version, artifact: artifactPath }, + ); const fileName = path.basename(artifactPath); - const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; + const url = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; + const downloadUrl = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`; const platforms = {}; + const downloads = {}; for (const key of resolveManifestPlatformKeys(target)) { platforms[key] = { signature, url }; + downloads[key] = { url: downloadUrl }; } return { version, ...(notes ? { notes } : {}), pub_date: publishedAt, platforms, + downloads, // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。 ...(commit ? { commit } : {}), }; @@ -657,14 +707,22 @@ export function formatRecentReleaseNotes(commits) { /** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ export function createLegacyUpdateManifest( artifactPath, - { channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {}, + { + channel = resolveReleaseChannel(), + target = defaultTarget(), + notes = readReleaseNotes(), + } = {}, ) { + const partition = resolveReleasePartition(channel, target); + if (partition !== 'dev-win') { + throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统'); + } const bytes = fs.readFileSync(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); return { version, - downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, + downloadUrl: `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length, ...(notes ? { releaseNotes: notes } : {}), @@ -675,12 +733,20 @@ export async function generateUpdateManifest( context = resolveReleaseContext(), ) { const { channel, target, bundleRoot } = context; - const artifact = selectReleaseArtifact(listFiles(bundleRoot), target); + const files = listFiles(bundleRoot); + const artifact = selectReleaseArtifact(files, target); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } + const downloadArtifact = selectFirstInstallArtifact(files, { + target, + version: readPackageJson().version, + artifact, + }); const manualNotes = readReleaseNotes(); - const previousCommit = await resolvePreviousReleaseCommit(channel); + const previousCommit = await resolvePreviousReleaseCommit(channel, { + target, + }); const commits = collectReleaseCommits(previousCommit); const recentCommits = previousCommit ? null : collectRecentReleaseCommits(); const notes = @@ -692,7 +758,12 @@ export async function generateUpdateManifest( `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`, ); } - const manifest = createUpdateManifest(artifact, { channel, target, notes }); + const manifest = createUpdateManifest(artifact, { + channel, + target, + notes, + downloadArtifact, + }); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); const notesPath = path.join(bundleRoot, 'release-notes.txt'); @@ -701,8 +772,8 @@ export async function generateUpdateManifest( notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n', ); const legacyManifest = - channel === 'dev-win' - ? createLegacyUpdateManifest(artifact, { channel, notes }) + channel === 'dev' && resolveReleasePlatform(target) === 'windows' + ? createLegacyUpdateManifest(artifact, { channel, target, notes }) : null; const legacyManifestPath = legacyManifest ? path.join(bundleRoot, 'legacy-latest.json') @@ -717,6 +788,7 @@ export async function generateUpdateManifest( `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`, ); console.log(`[ai-game-creator-shell] 安装包:${artifact}`); + console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`); console.log( manualNotes ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' @@ -732,7 +804,9 @@ export async function generateUpdateManifest( } return { channel, + target, artifact, + downloadArtifact, manifest, manifestPath, notes, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 8cb9c0214..83f96a0a0 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -30,14 +30,26 @@ import { resolvePreviousReleaseCommit, resolveReleaseChannel, resolveReleaseContext, + resolveReleasePartition, resolveRemoteHighWaterVersion, runTauriBuild, + selectFirstInstallArtifact, selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; +const packageVersion = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version; + +function createDmgFixture(root, target, version = packageVersion) { + const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64'; + const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`); + writeFileSync(dmg, 'first installation disk image'); + return dmg; +} test('native sidecar builds reject universal targets and accept each macOS architecture', () => { assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/); @@ -115,32 +127,60 @@ test('does not select unsupported files', () => { ); }); -test('resolves the channel from the target platform and rejects mismatches', () => { - assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win'); - assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac'); +test('channels are independent of platform and accept release and custom names', () => { + assert.equal(resolveReleaseChannel({}), 'dev'); + for (const channel of ['dev', 'release', 'beta-2', 'a'.repeat(32)]) { + assert.equal( + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }), + channel, + ); + assert.equal( + resolveReleasePartition(channel, windowsTarget), + `${channel}-win`, + ); + assert.equal( + resolveReleasePartition(channel, 'aarch64-apple-darwin'), + `${channel}-mac`, + ); + } assert.equal( - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget), + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: ' release ' }), + 'release', + ); + for (const channel of [ + '', + ' ', + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', + 'dev-win', 'dev-mac', - ); - assert.throws( - () => - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget), - /只能用于 darwin 目标/u, - ); - assert.throws( - () => - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget), - /未知发布渠道/u, - ); + 'Release', + '../dev', + 'a/b', + 'a_b', + '-beta', + 'beta-', + '1beta', + 'a'.repeat(33), + ]) { + assert.throws( + () => resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }), + /发布渠道无效/u, + ); + } }); test('channel manifest URL and build-time endpoint follow the channel', () => { withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { assert.equal( - updateManifestUrl('dev-win'), + updateManifestUrl('dev', windowsTarget), 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); - assert.deepEqual(createChannelConfig('dev-mac'), { + assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { plugins: { updater: { endpoints: [ @@ -149,6 +189,15 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }, }, }); + assert.equal( + updateManifestUrl('release', windowsTarget), + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/release-win/latest.json', + ); + assert.equal( + createChannelConfig('beta-2', 'x86_64-apple-darwin').plugins.updater + .endpoints[0], + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/beta-2-mac/latest.json', + ); }); }); @@ -174,7 +223,7 @@ test('release context resolves explicit targets before environment/default and f for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) { const context = resolveReleaseContext(args, env); assert.equal(context.target, 'aarch64-apple-darwin'); - assert.equal(context.channel, 'dev-mac'); + assert.equal(context.channel, 'dev'); assert.match( context.bundleRoot.replaceAll('\\', '/'), /target\/aarch64-apple-darwin\/release\/bundle$/, @@ -183,14 +232,14 @@ test('release context resolves explicit targets before environment/default and f } assert.throws( () => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }), - /只能用于 windows/, + /发布渠道无效/, ); } assert.equal(resolveReleaseContext([], {}).target, windowsTarget); assert.equal( resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' }) .channel, - 'dev-mac', + 'dev', ); for (const args of [ ['--target'], @@ -220,7 +269,10 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and prepareVersion: async (context) => { seenContexts.push(context); assert.equal( - await resolveRemoteHighWaterVersion(context.channel), + await resolveRemoteHighWaterVersion( + context.channel, + context.target, + ), '0.1.67', ); }, @@ -254,7 +306,13 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and ), artifact, ); - const manifest = createUpdateManifest(artifact, context); + const manifest = createUpdateManifest(artifact, { + ...context, + downloadArtifact: createDmgFixture( + path.dirname(artifact), + context.target, + ), + }); assert.deepEqual(Object.keys(manifest.platforms), [ 'darwin-aarch64', ]); @@ -272,35 +330,124 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and assert.ok(seenContexts.every((context) => context === seenContexts[0])); }); -test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => { - const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-')); +for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) { + test(`real manifest writer publishes the ${target} updater and first installer separately`, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-')); + try { + const artifact = path.join(root, '陶泥儿.app.tar.gz'); + writeFileSync(artifact, 'mac package'); + writeFileSync(`${artifact}.sig`, 'mac signature'); + writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + const downloadArtifact = createDmgFixture(root, target); + const context = { + ...resolveReleaseContext([`--target=${target}`], {}), + bundleRoot: root, + }; + const result = await withStubbedFetch( + (url) => { + assert.match(url, /\/dev-mac\/latest\.json$/); + return jsonResponse({}, 404); + }, + () => generateUpdateManifest(context), + ); + assert.equal(result.artifact, artifact); + assert.equal(result.downloadArtifact, downloadArtifact); + assert.equal(result.manifestPath, path.join(root, 'latest.json')); + assert.equal(result.legacyManifestPath, null); + const key = target.startsWith('aarch64') + ? 'darwin-aarch64' + : 'darwin-x86_64'; + assert.deepEqual(Object.keys(result.manifest.platforms), [key]); + assert.deepEqual(Object.keys(result.manifest.downloads), [key]); + assert.match( + result.manifest.platforms[key].url, + /\/dev-mac\/.*\.app\.tar\.gz$/, + ); + assert.equal( + decodeURIComponent( + new URL(result.manifest.downloads[key].url).pathname, + ), + `/agc/dev-mac/${packageVersion}/${path.basename(downloadArtifact)}`, + ); + assert.deepEqual( + JSON.parse(readFileSync(result.manifestPath, 'utf8')), + result.manifest, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('DMG selection ignores other versions and architectures but rejects missing, empty and ambiguous current packages', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-dmg-selection-')); try { - const artifact = path.join(root, '陶泥儿.app.tar.gz'); - writeFileSync(artifact, 'mac package'); - writeFileSync(`${artifact}.sig`, 'mac signature'); - writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); - const context = { - ...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}), - bundleRoot: root, + const target = 'aarch64-apple-darwin'; + const options = { + target, + version: '2.3.4', + artifact: path.join(root, '陶泥儿.app.tar.gz'), }; - const result = await withStubbedFetch( - (url) => { - assert.match(url, /\/dev-mac\/latest\.json$/); - return jsonResponse({}, 404); - }, - () => generateUpdateManifest(context), + const oldVersion = createDmgFixture(root, target, '2.3.3'); + const wrongArchitecture = createDmgFixture( + root, + 'x86_64-apple-darwin', + '2.3.4', + ); + assert.throws(() => selectFirstInstallArtifact([], options), /找到 0 个/u); + assert.throws( + () => + selectFirstInstallArtifact([oldVersion, wrongArchitecture], options), + /找到 0 个/u, + ); + const current = createDmgFixture(root, target, '2.3.4'); + assert.equal( + selectFirstInstallArtifact( + [oldVersion, wrongArchitecture, current], + options, + ), + current, + ); + writeFileSync(current, ''); + assert.throws( + () => selectFirstInstallArtifact([current], options), + /不存在或为空/u, + ); + writeFileSync(current, 'valid dmg'); + const second = path.join(root, '另一包_2.3.4_aarch64.dmg'); + writeFileSync(second, 'ambiguous dmg'); + assert.throws( + () => selectFirstInstallArtifact([current, second], options), + /找到 2 个/u, ); - assert.equal(result.artifact, artifact); - assert.equal(result.manifestPath, path.join(root, 'latest.json')); - assert.equal(result.legacyManifestPath, null); - assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']); - assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//); } finally { rmSync(root, { recursive: true, force: true }); } }); -test('invalid target or mismatched channel fails before any release side effect', async () => { +test('manifest writer refuses to create latest when the current Mac DMG is missing', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-missing-dmg-')); + try { + const artifact = path.join(root, '陶泥儿.app.tar.gz'); + writeFileSync(artifact, 'updater archive'); + writeFileSync(`${artifact}.sig`, 'signature'); + const context = { + ...resolveReleaseContext(['--target=aarch64-apple-darwin'], {}), + bundleRoot: root, + }; + await assert.rejects( + () => generateUpdateManifest(context), + /首装 DMG 必须唯一匹配/u, + ); + assert.throws(() => readFileSync(path.join(root, 'latest.json')), { + code: 'ENOENT', + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('invalid target or platform used as channel fails before any release side effect', async () => { let touched = false; const sideEffects = { prepareVersion: () => { @@ -320,7 +467,7 @@ test('invalid target or mismatched channel fails before any release side effect' await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => assert.rejects( () => buildRelease(['--target=aarch64-apple-darwin'], sideEffects), - /只能用于 windows/, + /发布渠道无效/, ), ); assert.equal(touched, false); @@ -334,7 +481,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme AGC_BUILD_TARGET: 'aarch64-apple-darwin', }), ]) { - assert.equal(context.channel, 'dev-win'); + assert.equal(context.channel, 'dev'); assert.equal( selectReleaseArtifact(files, context.target), '/tmp/windows.exe', @@ -344,7 +491,11 @@ test('Windows remains the default and explicit Windows overrides macOS environme context, { spawn: (_binary, command) => { - assert.ok(command.includes('--features=cocos-editor-execute')); + assert.ok( + command.includes( + '--features=cocos-editor-execute,unity-editor-execute', + ), + ); assert.ok(command.includes('user-config.json')); const configIndex = command.lastIndexOf('--config'); const config = JSON.parse( @@ -374,14 +525,14 @@ test('no-bundle smoke skips version writes and manifest generation', async () => steps.push('manifest'); }, }); - assert.deepEqual(steps, ['dev-mac']); + assert.deepEqual(steps, ['dev']); }); test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { const manifest = createUpdateManifest(artifact, { - channel: 'dev-win', + channel: 'dev', target: windowsTarget, publishedAt: '2026-09-17T00:00:00.000Z', }); @@ -389,6 +540,9 @@ test('channel manifest carries version, platform keys and signature', () => { assert.equal(manifest.notes, '修复与改进'); assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z'); assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']); + assert.deepEqual(manifest.downloads, { + 'windows-x86_64': { url: manifest.platforms['windows-x86_64'].url }, + }); assert.equal( manifest.platforms['windows-x86_64'].signature, 'signature-content', @@ -409,7 +563,7 @@ test('missing signature fails the channel manifest closed', () => { assert.throws( () => createUpdateManifest(artifact, { - channel: 'dev-win', + channel: 'dev', target: windowsTarget, }), /缺少更新包签名/u, @@ -422,7 +576,7 @@ test('missing signature fails the channel manifest closed', () => { test('legacy manifest keeps the sha256 contract of published clients', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { const legacy = createLegacyUpdateManifest(artifact, { - channel: 'dev-win', + channel: 'dev', }); assert.match(legacy.version, /^\d+\.\d+\.\d+$/u); assert.equal(legacy.sha256.length, 64); @@ -438,6 +592,86 @@ test('next release version follows the higher local or channel version', () => { assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); }); +for (const channel of ['release', 'beta-2']) { + for (const target of [windowsTarget, 'aarch64-apple-darwin']) { + test(`${channel} ${target} freezes its endpoint, version source and published objects`, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-manifest-')); + try { + const windows = target === windowsTarget; + const partition = `${channel}-${windows ? 'win' : 'mac'}`; + const artifact = path.join( + root, + windows ? '陶泥儿_x64-setup.exe' : '陶泥儿.app.tar.gz', + ); + writeFileSync(artifact, 'updater package'); + writeFileSync(`${artifact}.sig`, 'updater signature'); + if (!windows) createDmgFixture(root, target); + const context = { + ...resolveReleaseContext([`--target=${target}`], { + AGC_UPDATE_CHANNEL: channel, + }), + bundleRoot: root, + }; + const requests = []; + const result = await withStubbedFetch( + (url) => { + requests.push(url); + assert.ok(url.endsWith(`/agc/${partition}/latest.json`)); + return jsonResponse({ + version: '2.3.4', + commit: 'abcdef1234567890', + }); + }, + async () => { + assert.equal( + await resolveRemoteHighWaterVersion( + context.channel, + context.target, + ), + '2.3.4', + ); + runTauriBuild([`--target=${target}`], context, { + spawn: (_binary, command) => { + const config = JSON.parse( + readFileSync( + command[command.lastIndexOf('--config') + 1], + 'utf8', + ), + ); + assert.ok( + config.plugins.updater.endpoints[0].endsWith( + `/agc/${partition}/latest.json`, + ), + ); + return { status: 0 }; + }, + }); + return generateUpdateManifest(context); + }, + ); + assert.equal(result.channel, channel); + assert.equal(result.target, target); + assert.equal(result.manifest.version, packageVersion); + assert.equal(result.legacyManifestPath, null); + assert.equal(result.legacyManifest, null); + assert.equal(requests.length, 2); + for (const entry of [ + ...Object.values(result.manifest.platforms), + ...Object.values(result.manifest.downloads), + ]) { + assert.ok(entry.url.includes(`/agc/${partition}/${packageVersion}/`)); + } + assert.throws( + () => createLegacyUpdateManifest(artifact, { channel, target }), + /只属于 dev 渠道/u, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + } +} + test('version high water keeps the legacy pointer during the migration window', async () => { await withStubbedFetch( (url) => @@ -445,7 +679,10 @@ test('version high water keeps the legacy pointer during the migration window', ? jsonResponse({}, 404) : jsonResponse({ version: '0.1.57' }), async () => { - assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57'); + assert.equal( + await resolveRemoteHighWaterVersion('dev', windowsTarget), + '0.1.57', + ); // 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。 assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58'); }, @@ -459,7 +696,10 @@ test('version high water takes the higher of channel and legacy pointer', async ? jsonResponse({ version: '0.1.60' }) : jsonResponse({ version: '0.1.57' }), async () => { - assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60'); + assert.equal( + await resolveRemoteHighWaterVersion('dev', windowsTarget), + '0.1.60', + ); }, ); }); @@ -474,7 +714,16 @@ test('version high water ignores the windows migration pointer for other channel return jsonResponse({ version: '0.1.12' }); }, async () => { - assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12'); + for (const [channel, target] of [ + ['dev', 'aarch64-apple-darwin'], + ['release', windowsTarget], + ['beta-2', windowsTarget], + ]) { + assert.equal( + await resolveRemoteHighWaterVersion(channel, target), + '0.1.12', + ); + } }, ); }); @@ -484,20 +733,20 @@ test('release notes anchor prefers the explicit commit and falls back to the man () => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }), async () => { assert.equal( - await resolvePreviousReleaseCommit('dev-win', { + await resolvePreviousReleaseCommit('dev', { override: '6017d46088c04199e99cf89f347b12d67591475e', }), '6017d46088c04199e99cf89f347b12d67591475e', ); // 覆盖值非法时忽略,继续用清单里的 commit。 assert.equal( - await resolvePreviousReleaseCommit('dev-win', { + await resolvePreviousReleaseCommit('dev', { override: 'not-a-sha', }), 'abcdef1234567890', ); assert.equal( - await resolvePreviousReleaseCommit('dev-win', { override: ' ' }), + await resolvePreviousReleaseCommit('dev', { override: ' ' }), 'abcdef1234567890', ); }, @@ -507,7 +756,7 @@ test('release notes anchor prefers the explicit commit and falls back to the man () => jsonResponse({ version: '0.1.61' }), async () => { assert.equal( - await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + await resolvePreviousReleaseCommit('dev', { override: undefined }), null, ); }, @@ -521,7 +770,7 @@ test('release notes anchor degrades to null when the manifest cannot be read', a }; try { assert.equal( - await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + await resolvePreviousReleaseCommit('dev', { override: undefined }), null, ); } finally { @@ -569,18 +818,18 @@ test('recent commit fallback marks that entries may repeat the previous release' } }); -test('release upload forces overwrite for artifact, signature and channel pointers', () => { +test('release entry forwards the built artifacts and dry-run mode to the uploader', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), 'utf8', ); - assert.equal( - (source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length, - 4, + assert.match( + source, + /const release = await buildRelease\(process\.argv\.slice\(2\)\)/u, ); - assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); - assert.match(source, /agc\/latest\.json/u); - assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u); + assert.match(source, /uploadReleaseArtifacts\(release, \{/u); + assert.match(source, /const dryRun = readReleaseDryRun\(\);/u); + assert.ok(source.includes('\n dryRun,\n')); }); test('release notes list client commits with short sha and bound their size', () => { diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index de476e0a7..b9282a77f 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -19,6 +19,6 @@ export function withDefaultCargoFeatures(argv, features) { export function defaultEditorFeatures(target) { return target === 'win32' || target.includes('windows') - ? ['cocos-editor-execute'] + ? ['cocos-editor-execute', 'unity-editor-execute'] : []; } diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs index 80e31e9be..97592ea11 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs @@ -9,7 +9,7 @@ test('Windows release includes the same editor feature as development', () => { buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'), [ 'build', - '--features=cocos-editor-execute', + '--features=cocos-editor-execute,unity-editor-execute', '--target', 'x86_64-pc-windows-msvc', ], diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c368667da..cc77c7b23 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -127,8 +127,7 @@ const allowedUncalledTauriCommands = [ 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_direct_project_conversation', - // 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用; - // 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。 + // 前端登记工程生命周期,上传由 Rust 调度;以下两个命令仅供本机排障。 'read_local_project_snapshot_state', 'sync_local_project_snapshot', 'reset_design_agent_session', @@ -143,7 +142,6 @@ const allowedUncalledTauriCommands = [ 'reload_agc_plugin', 'call_agc_plugin', 'read_agc_plugin_panel', - 'set_agc_plugin_project_path', 'set_agc_plugin_enabled', ]; const sourceExtensions = new Set([ @@ -1590,11 +1588,13 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) { ); } -if (!( - tauriConfig.build?.beforeDevCommand?.includes( - 'run ai-game-creator-shell:dev-server', - ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') -)) { +if ( + !( + tauriConfig.build?.beforeDevCommand?.includes( + 'run ai-game-creator-shell:dev-server', + ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') + ) +) { throw new Error( 'AI game creator shell beforeDevCommand must start the selected Vite dev server', ); diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index c0a748ad3..9c8318c57 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -290,7 +290,7 @@ function deterministicArtManifest( { id: 'garden-guardians-spritesheet', path: 'assets/art-spritesheet.png', - kind: 'art-spritesheet', + kind: 'icon-spritesheet', usage: ['defenders', 'enemies', 'battlefield-ui'], source: 'canvas', status: 'ready', @@ -710,7 +710,7 @@ function canvasAssetCall(agentId) { outputPath: 'assets/ui-prototype.png', aspectRatio: '16:9', imageSize: '2K', - assetKind: 'ui-prototype', + assetKind: 'ui-design', assetLabel: '游戏横屏界面原型图', replaceExisting: false, }); @@ -721,7 +721,7 @@ function canvasAssetCall(agentId) { outputPath: 'assets/art-spritesheet.png', aspectRatio: '1:1', imageSize: '1K', - assetKind: 'art-spritesheet', + assetKind: 'icon-spritesheet', assetLabel: '游戏首版核心美术素材', replaceExisting: false, sliceMode: 'connected-components', @@ -830,12 +830,12 @@ function missingGeneratedVisualAssetObservation(context, agentId) { }, 'design-foundation': { path: 'assets/ui-prototype.png', - kind: 'ui-prototype', + kind: 'ui-design', summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务', }, 'art-asset-plan': { path: 'assets/art-spritesheet.png', - kind: 'art-spritesheet', + kind: 'icon-spritesheet', summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务', }, }[agentId]; @@ -2667,7 +2667,7 @@ function createDeterministicCanvasFixture(apiKey) { asset: { assetId: `asset-${sliceImageId}`, assetObjectId: sliceAssetObjectId, - assetKind: 'art-spritesheet-slice', + assetKind: 'icon', projectId, taskId, }, @@ -2707,7 +2707,7 @@ function createDeterministicCanvasFixture(apiKey) { spritesheetAsset: { assetId: `asset-${imageId}`, assetObjectId, - assetKind: 'art-spritesheet', + assetKind: 'icon-spritesheet', projectId, taskId, }, @@ -2749,7 +2749,7 @@ function createDeterministicCanvasFixture(apiKey) { const assetObjectId = `asset-object-${imageId}`; const resourceId = `resource-${imageId}`; const assetKind = - typeof body?.assetKind === 'string' ? body.assetKind : 'game-art'; + typeof body?.assetKind === 'string' ? body.assetKind : 'image'; images.set(imageId, { ...image, objectKey, diff --git a/apps/ai-game-creator-shell/scripts/release-oss.mjs b/apps/ai-game-creator-shell/scripts/release-oss.mjs index 0e19ee81b..7f0caac10 100644 --- a/apps/ai-game-creator-shell/scripts/release-oss.mjs +++ b/apps/ai-game-creator-shell/scripts/release-oss.mjs @@ -1,3 +1,8 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +import { resolveReleasePartition } from './build-release.mjs'; + /** * 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式, * 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。 @@ -25,3 +30,101 @@ export function formatOssutilCommand({ binary, args, endpoint, credentials }) { } return parts.map(quoteArgument).join(' '); } + +export function createReleaseUploadPlan( + { + artifact, + downloadArtifact, + channel, + target, + manifest, + manifestPath, + legacyManifestPath, + }, + bucket, +) { + if ( + !artifact || + !downloadArtifact || + !manifestPath || + !manifest?.version || + !channel || + !target + ) { + throw new Error('发布结果缺少渠道、构建目标、更新包、首装包或清单'); + } + const partition = resolveReleasePartition(channel, target); + if (legacyManifestPath && partition !== 'dev-win') { + throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统'); + } + const prefix = `oss://${bucket}/agc/${partition}`; + const artifacts = [ + ...new Set( + [artifact, `${artifact}.sig`, downloadArtifact].map((file) => + path.resolve(file), + ), + ), + ]; + const plan = artifacts.map((source) => ({ + source, + destination: `${prefix}/${manifest.version}/${path.basename(source)}`, + })); + plan.push({ source: manifestPath, destination: `${prefix}/latest.json` }); + if (legacyManifestPath) { + plan.push({ + source: legacyManifestPath, + destination: `oss://${bucket}/agc/latest.json`, + }); + } + return plan; +} + +export function uploadReleaseArtifacts( + release, + { + bucket, + endpoint, + binary = 'ossutil', + accessKeyId, + accessKeySecret, + dryRun = false, + spawn = spawnSync, + log = console.log, + }, +) { + if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) { + throw new Error('OSS AccessKey ID 和 Secret 必须同时提供'); + } + const plan = createReleaseUploadPlan(release, bucket); + for (const { source, destination } of plan) { + // 全部安装对象成功后才执行 latest 指针;失败立即终止,不发布悬空链接。 + const args = ['cp', '--force', source, destination]; + if (dryRun) { + log( + `[dry-run] ${formatOssutilCommand({ binary, args, endpoint, credentials: Boolean(accessKeyId) })}`, + ); + continue; + } + const credentials = accessKeyId + ? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret] + : []; + const result = spawn( + binary, + [...args, '--endpoint', endpoint, ...credentials], + { + stdio: 'inherit', + shell: false, + }, + ); + if (result.error) + throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`); + if (result.status !== 0) { + throw new Error( + `OSS 上传失败(退出码 ${result.status ?? 1}):${destination}`, + ); + } + log(`[ai-game-creator-shell] 已上传 ${destination}`); + } + if (dryRun) log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象'); + return plan; +} diff --git a/apps/ai-game-creator-shell/scripts/release-oss.test.mjs b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs index 0e38efc9d..0f6a70f13 100644 --- a/apps/ai-game-creator-shell/scripts/release-oss.test.mjs +++ b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs @@ -1,8 +1,15 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { test } from 'node:test'; -import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs'; +import { + createReleaseUploadPlan, + formatOssutilCommand, + readReleaseDryRun, + uploadReleaseArtifacts, +} from './release-oss.mjs'; test('dry run only accepts explicit truthy values', () => { assert.equal(readReleaseDryRun({}), false); @@ -33,12 +40,198 @@ test('printed upload command keeps arguments and hides credentials', () => { ); }); -test('uploader gates every ossutil call behind the dry run switch', () => { - const source = readFileSync( - new URL('./release-upload.mjs', import.meta.url), - 'utf8', +function withReleaseFixture(channel, architecture, run, platform = 'macos') { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-')); + try { + const artifact = path.join( + root, + platform === 'windows' + ? '陶泥儿_1.2.3_x64-setup.exe' + : '陶泥儿.app.tar.gz', + ); + const downloadArtifact = + platform === 'windows' + ? artifact + : path.join(root, `陶泥儿_1.2.3_${architecture}.dmg`); + const manifestPath = path.join(root, 'latest.json'); + const legacyManifestPath = + channel === 'dev' && platform === 'windows' + ? path.join(root, 'legacy-latest.json') + : null; + for (const file of [ + artifact, + `${artifact}.sig`, + downloadArtifact, + manifestPath, + legacyManifestPath, + ].filter(Boolean)) { + writeFileSync(file, 'fixture'); + } + return run({ + artifact, + downloadArtifact, + channel, + target: + platform === 'windows' + ? 'x86_64-pc-windows-msvc' + : `${architecture === 'aarch64' ? 'aarch64' : 'x86_64'}-apple-darwin`, + manifest: { version: '1.2.3' }, + manifestPath, + legacyManifestPath, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const uploadOptions = { + bucket: 'agc-dev', + endpoint: 'oss-rg-china-mainland.aliyuncs.com', + log: () => {}, +}; + +for (const architecture of ['aarch64', 'x64']) { + test(`uploads every ${architecture} Mac object before the channel pointer`, () => { + withReleaseFixture('dev', architecture, (release) => { + const calls = []; + uploadReleaseArtifacts(release, { + ...uploadOptions, + spawn: (binary, args, options) => { + assert.equal(binary, 'ossutil'); + assert.equal(options.shell, false); + assert.deepEqual(args.slice(0, 2), ['cp', '--force']); + calls.push({ source: args[2], destination: args[3] }); + return { status: 0 }; + }, + }); + assert.deepEqual( + calls.map(({ source }) => source), + [ + release.artifact, + `${release.artifact}.sig`, + release.downloadArtifact, + release.manifestPath, + ], + ); + assert.equal( + calls[2].destination, + `oss://agc-dev/agc/dev-mac/1.2.3/陶泥儿_1.2.3_${architecture}.dmg`, + ); + assert.equal( + calls[3].destination, + 'oss://agc-dev/agc/dev-mac/latest.json', + ); + }); + }); +} + +test('Windows uploads the shared installer once and publishes migration metadata last', () => { + withReleaseFixture( + 'dev', + 'x64', + (release) => { + const plan = createReleaseUploadPlan(release, 'agc-dev'); + assert.deepEqual( + plan.map(({ source }) => source), + [ + release.artifact, + `${release.artifact}.sig`, + release.manifestPath, + release.legacyManifestPath, + ], + ); + assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json'); + const calls = []; + uploadReleaseArtifacts(release, { + ...uploadOptions, + spawn: (_binary, args) => { + assert.deepEqual(args.slice(0, 2), ['cp', '--force']); + calls.push(args[3]); + return { status: 0 }; + }, + }); + assert.deepEqual( + calls, + plan.map(({ destination }) => destination), + ); + }, + 'windows', ); - assert.match(source, /const dryRun = readReleaseDryRun\(\);/u); - assert.match(source, /if \(dryRun\) \{/u); - assert.match(source, /dry-run:未写入任何 OSS 对象/u); }); + +for (const failedArtifactIndex of [0, 1, 2]) { + test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => { + withReleaseFixture('dev', 'aarch64', (release) => { + const destinations = []; + assert.throws( + () => + uploadReleaseArtifacts(release, { + ...uploadOptions, + spawn: (_binary, args) => { + destinations.push(args[3]); + return { + status: destinations.length - 1 === failedArtifactIndex ? 1 : 0, + }; + }, + }), + /OSS 上传失败/u, + ); + assert.equal(destinations.length, failedArtifactIndex + 1); + assert.ok( + destinations.every( + (destination) => !destination.endsWith('/latest.json'), + ), + ); + }); + }); +} + +test('dry run prints the complete plan without spawning uploads or exposing credentials', () => { + withReleaseFixture('dev', 'aarch64', (release) => { + const output = []; + uploadReleaseArtifacts(release, { + ...uploadOptions, + dryRun: true, + accessKeyId: 'fixture-id', + accessKeySecret: 'fixture-secret', + spawn: () => assert.fail('dry run must never execute ossutil'), + log: (line) => output.push(line), + }); + assert.equal( + output.filter((line) => line.startsWith('[dry-run]')).length, + 4, + ); + assert.match(output.join('\n'), /\.dmg/u); + assert.match(output.at(-1), /未写入任何 OSS 对象/u); + assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u); + }); +}); + +for (const channel of ['release', 'beta-2']) { + for (const platform of ['windows', 'macos']) { + test(`${channel} ${platform} uploads only its own partition and cannot write the dev bridge`, () => { + withReleaseFixture( + channel, + 'x64', + (release) => { + const plan = createReleaseUploadPlan(release, 'agc-dev'); + const suffix = platform === 'windows' ? 'win' : 'mac'; + const prefix = `oss://agc-dev/agc/${channel}-${suffix}/`; + assert.ok( + plan.every(({ destination }) => destination.startsWith(prefix)), + ); + assert.equal(plan.at(-1).destination, `${prefix}latest.json`); + assert.throws( + () => + createReleaseUploadPlan( + { ...release, legacyManifestPath: release.manifestPath }, + 'agc-dev', + ), + /只属于 dev 渠道/u, + ); + }, + platform, + ); + }); + } +} diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index d39b2fa9f..1d86fb276 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -1,7 +1,4 @@ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs'; +import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; const endpoint = @@ -14,76 +11,12 @@ const dryRun = readReleaseDryRun(); const { buildRelease } = await import('./build-release.mjs'); -function runOssutil(args) { - const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; - const accessKeyId = process.env.AGC_OSS_ACCESS_KEY_ID?.trim(); - const accessKeySecret = process.env.AGC_OSS_ACCESS_KEY_SECRET; - if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) { - throw new Error('OSS AccessKey ID 和 Secret 必须同时提供'); - } - if (dryRun) { - // 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。 - console.log( - `[dry-run] ${formatOssutilCommand({ - binary, - args, - endpoint, - credentials: Boolean(accessKeyId), - })}`, - ); - return; - } - const credentialArgs = accessKeyId - ? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret] - : []; - const result = spawnSync( - binary, - [...args, '--endpoint', endpoint, ...credentialArgs], - { - stdio: 'inherit', - shell: false, - }, - ); - if (result.error) { - throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`); - } - if (result.status !== 0) process.exit(result.status ?? 1); -} - -const { artifact, channel, legacyManifestPath, manifest, manifestPath } = - await buildRelease(process.argv.slice(2)); -const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; -// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; -// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 -runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]); -runOssutil([ - 'cp', - '--force', - `${artifact}.sig`, - `oss://${bucket}/${artifactKey}.sig`, -]); -runOssutil([ - 'cp', - '--force', - manifestPath, - `oss://${bucket}/agc/${channel}/latest.json`, -]); -console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`); -console.log( - `[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`, -); -if (legacyManifestPath) { - // 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。 - runOssutil([ - 'cp', - '--force', - legacyManifestPath, - `oss://${bucket}/agc/latest.json`, - ]); - console.log( - `[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`, - ); -} -if (dryRun) { - console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象'); -} +const release = await buildRelease(process.argv.slice(2)); +uploadReleaseArtifacts(release, { + bucket, + endpoint, + binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil', + accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(), + accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET, + dryRun, +}); diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 3ab0f2ced..e619c7123 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -1,7 +1,10 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { withDefaultCargoFeatures } from './cargo-features.mjs'; +import { + defaultEditorFeatures, + withDefaultCargoFeatures, +} from './cargo-features.mjs'; import { readAgcDevEndpoint, resolveAgcDevEndpoint, @@ -47,9 +50,8 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { ]; } -// `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature 下 -// 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具, -// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 +// 开发和发行构建使用同一平台编辑器 feature 集合。 +// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 function readDevCargoFeatures(env = process.env) { const override = env.AGC_DEV_CARGO_FEATURES; if (override !== undefined) { @@ -58,7 +60,7 @@ function readDevCargoFeatures(env = process.env) { .map((value) => value.trim()) .filter(Boolean); } - return process.platform === 'win32' ? ['cocos-editor-execute'] : []; + return defaultEditorFeatures(process.platform); } function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index bc1227832..57450d448 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1793,6 +1793,7 @@ dependencies = [ "ttf-parser", "typed_floats", "unicode-normalization", + "unity-editor-bridge", "url", "uuid", "windows-sys 0.61.2", @@ -3931,6 +3932,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tracing", ] [[package]] @@ -5016,6 +5018,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "ts-rs", ] [[package]] @@ -6407,6 +6410,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unity-editor-bridge" +version = "0.1.0" +dependencies = [ + "editor-adapter-api", + "serde", + "serde_json", + "windows-sys 0.61.2", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 5c3e8eb64..4647fdfc9 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -12,6 +12,7 @@ template-library-fixtures = [] cocos-editor = ["cocos-editor-bridge/process-discovery"] cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"] cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"] +unity-editor-execute = [] [build-dependencies] serde = { version = "1", features = ["derive"] } @@ -27,6 +28,7 @@ nalgebra = { version = "0.35.0", features = ["serde-serialize"] } agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false } editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" } +unity-editor-bridge = { path = "../../../plugins/agc-unity-editor/native/unity-editor-bridge" } base64 = "0.22" axum = "0.8" chromiumoxide = "0.9.1" @@ -56,7 +58,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" } portable-pty = "0.9" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } regex = "1" -shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } +shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 6bca6d67a..c39386b4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -196,6 +196,7 @@ fn main() { ); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); stage_bundled_codex_cli(&manifest_dir); + prepare_unity_editor_helper(&manifest_dir); stage_plugin_workspace(&manifest_dir); stage_cocos_editor_payload(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) @@ -266,6 +267,107 @@ fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) { #[cfg(not(windows))] fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} +/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。 +fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) { + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE"); + let target = env::var("TARGET").expect("Cargo TARGET"); + if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none() + || target != "x86_64-pc-windows-msvc" + { + return; + } + let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet"); + let mut sources = Vec::new(); + collect_unity_helper_sources(&root, &mut sources); + sources.sort(); + let mut fingerprint = Sha256::new(); + for source in &sources { + println!("cargo:rerun-if-changed={}", source.display()); + fingerprint.update( + source + .strip_prefix(&root) + .expect("helper source") + .to_string_lossy() + .as_bytes(), + ); + fingerprint.update([0]); + fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败")); + } + let fingerprint = format!("{:x}", fingerprint.finalize()); + let publish = root.join("publish/win-x64"); + let executable = publish.join("Agc.Unity.Attach.exe"); + let stamp = publish.join(".agc-source.sha256"); + println!("cargo:rerun-if-changed={}", executable.display()); + if unity_helper_publish_complete(&publish) + && fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint) + { + return; + } + assert!( + cfg!(windows), + "构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链" + ); + let status = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + ]) + .arg(root.join("build.ps1")) + .current_dir(&root) + .status() + .expect("无法启动 Unity helper 构建脚本"); + assert!( + status.success() && unity_helper_publish_complete(&publish), + "Unity helper 构建失败或缺少运行文件/许可" + ); + fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败"); +} + +fn unity_helper_publish_complete(publish: &std::path::Path) -> bool { + [ + "Agc.Unity.Attach.exe", + "NOTICE", + "THIRD-PARTY-NOTICES.txt", + "licenses/DotCraft-Apache-2.0.txt", + "licenses/Roslyn-MIT.txt", + "licenses/upstream.json", + "licenses/dotnet-LICENSE.TXT", + "licenses/dotnet-THIRD-PARTY-NOTICES.TXT", + "licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", + "licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf", + ] + .iter() + .all(|name| { + fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| { + metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0 + }) + }) +} + +fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec) { + for entry in fs::read_dir(root) + .expect("Unity helper 源码目录缺失") + .flatten() + { + let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败"); + assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接"); + let name = entry.file_name(); + if kind.is_dir() { + if !matches!( + name.to_str(), + Some("bin" | "obj" | "publish" | "native-build") + ) { + collect_unity_helper_sources(&entry.path(), sources); + } + } else if kind.is_file() { + sources.push(entry.path()); + } + } +} + /// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。 /// /// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 @@ -313,9 +415,15 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { for relative in [ std::path::PathBuf::from("src"), std::path::PathBuf::from("panels"), + std::path::PathBuf::from("skills"), std::path::PathBuf::from("native/payload"), + std::path::PathBuf::from("dotnet/publish/win-x64"), ] { - if relative == std::path::Path::new("native/payload") && !target.contains("windows") { + if (relative == std::path::Path::new("native/payload") && !target.contains("windows")) + || (relative == std::path::Path::new("dotnet/publish/win-x64") + && (target != "x86_64-pc-windows-msvc" + || env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none())) + { continue; } copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); 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 d1a51e9af..0b9f67ba9 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 @@ -63,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c" + "sha256": "6668bf1aa69601bcc65c97fdcd879c699c3139befcd70805d95614997f6e44e9" }, { "name": "agc-web-game-development", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md index 2c737d022..2ed440460 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md @@ -19,13 +19,13 @@ image, UI design image, or publication material; use `agc_edit_image` for an edit of an existing registered image; use `taonier_prepare_game_art` only for the complete game-art package and its canonical slices. -With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"` +With `agc_generate_image`, `kind="character"` and `kind="icon-spritesheet"` generate the subject on a solid-colour background and automatically matte it away afterwards, producing transparent-background results; write the prompt for the subject only, never for a scene. `kind="image"` keeps the rendered frame without extra processing. -When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is +When `agc_generate_image` is used with `kind="icon-spritesheet"`, `sliceMode` is required and has no default, so decide it explicitly: - Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs index 86416a42d..952cfbe93 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs @@ -1198,7 +1198,7 @@ mod tests { "tool": "agc_generate_image", "arguments": { "prompt": prompt, - "kind": "art-spritesheet", + "kind": "icon-spritesheet", "sliceMode": "connected-components", "sliceCount": 8, "screenColor": "#CFEFFF" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 2ab90cc62..f074f18ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -1,6 +1,10 @@ -use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; +use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem}; use super::validation::validate_direct_codex_user_item; use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; +use crate::ui_editor::persistence::{ + generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND, + UI_DESIGN_DOC_MEDIA_TYPE, +}; use serde_json::Value; use std::path::Path; @@ -111,29 +115,165 @@ pub(crate) fn direct_codex_user_item_to_prompt( item: &DirectCodexUserItem, ) -> Result { let wire = direct_codex_user_item_to_wire_input(root, item)?; - wire.as_array() + let DirectCodexUserItem::Message(message) = item; + let mut prompt = wire + .as_array() .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) - .map(|parts| { + .and_then(|parts| { parts .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::() - }) - .and_then(|prompt| { - if prompt.trim().is_empty() { - Err("DirectProject user item 不能转换为空 prompt".to_string()) - } else { - Ok(prompt) + .map(|part| { + part.get("text") + .and_then(Value::as_str) + .ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string()) + }) + .collect::>() + })?; + if let Some(code_context) = render_ui_design_code_context(root, message)? { + prompt.push('\n'); + prompt.push_str(&code_context); + } + if prompt.trim().is_empty() { + return Err("DirectProject user item 不能转换为空 prompt".to_string()); + } + Ok(prompt) +} + +/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的 +/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。 +/// +/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item` +/// 的纯投影,不得在这里产生项目写副作用。 +fn render_ui_design_code_context( + root: &Path, + message: &DirectCodexUserMessageItem, +) -> Result, String> { + let referenced_ids = message + .content + .iter() + .filter_map(|part| match part { + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + Some(resource_id.trim()) } + _ => None, }) + .collect::>(); + if referenced_ids.is_empty() { + return Ok(None); + } + let manifest = read_manifest_for_project(root)?; + let mut lines = Vec::new(); + for resource_id in referenced_ids { + let is_ui_design_doc = manifest.assets.iter().any(|asset| { + asset.id == resource_id + && asset.kind == UI_DESIGN_DOC_ASSET_KIND + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }); + if !is_ui_design_doc { + continue; + } + lines.push( + match generate_ui_design_code_at(GenerateUiDesignCodeInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + asset_id: resource_id.to_string(), + }) { + Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path), + Err(error) => format!("生成代码遇到错误{error}"), + }, + ); + } + if lines.is_empty() { + return Ok(None); + } + Ok(Some(lines.join("\n"))) } #[cfg(test)] mod tests { - use super::direct_codex_user_item_to_response_item; + use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item}; + use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; use serde_json::json; + use shared_contracts::game_creation_app::{ + GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, + }; use std::path::Path; + const PROMPT_CONTEXT_PROJECT_ID: &str = "direct-prompt-context-project"; + + fn prompt_context_project() -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at( + directory.path(), + PROMPT_CONTEXT_PROJECT_ID, + "Direct prompt 上下文测试", + ) + .expect("init project"); + directory + } + + fn register_fixture_asset( + root: &Path, + relative_path: &str, + kind: GameCreationAppAssetKind, + media_type: &str, + ) -> String { + let absolute_path = crate::resolve_local_project_path(root, relative_path) + .expect("resolve fixture asset path"); + std::fs::create_dir_all(absolute_path.parent().expect("fixture asset parent")) + .expect("create fixture asset parent"); + std::fs::write(&absolute_path, b"{}").expect("write fixture asset file"); + crate::assets::register_local_asset_at( + root, + relative_path, + kind, + media_type, + "prompt-context-test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: Some("prompt-context".to_string()), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register fixture asset") + .id + } + + fn ui_design_doc_fixture(initialize_state: bool) -> (tempfile::TempDir, String) { + let directory = prompt_context_project(); + let asset_id = register_fixture_asset( + directory.path(), + "ui/design.json", + GameCreationAppAssetKind::UiDesignDoc, + UI_DESIGN_DOC_MEDIA_TYPE, + ); + if initialize_state { + crate::ui_editor::persistence::initialize_ui_design_state_at( + directory.path(), + PROMPT_CONTEXT_PROJECT_ID, + &asset_id, + ) + .expect("initialize UI design state"); + } + (directory, asset_id) + } + + fn user_item_with_resource_reference(asset_id: &str) -> serde_json::Value { + json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [{"type": "agc_resource_reference", "resourceId": asset_id}], + }) + } + #[test] fn standard_response_item_passes_through_without_agc_private_parts() { let item = json!({ @@ -174,4 +314,81 @@ mod tests { .expect_err("history item without type must fail"); assert!(error.contains("缺少 type"), "{error}"); } + + #[test] + fn ui_design_doc_reference_appends_generated_code_context() { + let (project, asset_id) = ui_design_doc_fixture(true); + let item: super::DirectCodexUserItem = + serde_json::from_value(user_item_with_resource_reference(&asset_id)) + .expect("canonical user item"); + let prompt = + direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + assert!( + prompt.contains(&format!( + "[素材引用 resourceId={asset_id};项目路径=ui/design.json]" + )), + "{prompt}" + ); + assert!( + prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"), + "{prompt}" + ); + } + + #[test] + fn history_projection_never_writes_generated_ui_design_code() { + let (project, asset_id) = ui_design_doc_fixture(true); + let item = user_item_with_resource_reference(&asset_id); + direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection"); + let generated_root = + crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory"); + let generated_files = std::fs::read_dir(generated_root) + .expect("read ui directory") + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("generated-") + }) + .count(); + assert_eq!( + generated_files, 0, + "历史回读只做纯投影,不得生成 UI 设计代码" + ); + } + + #[test] + fn ui_design_generation_failure_keeps_reference_and_reports_error() { + let (project, asset_id) = ui_design_doc_fixture(false); + let item: super::DirectCodexUserItem = + serde_json::from_value(user_item_with_resource_reference(&asset_id)) + .expect("canonical user item"); + let prompt = + direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); + assert!(prompt.contains("生成代码遇到错误"), "{prompt}"); + } + + #[test] + fn other_asset_kind_reference_does_not_generate_ui_design_code() { + let project = prompt_context_project(); + let asset_id = register_fixture_asset( + project.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + let item: super::DirectCodexUserItem = + serde_json::from_value(user_item_with_resource_reference(&asset_id)) + .expect("canonical user item"); + let prompt = + direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); + assert!( + !prompt.contains("请先阅读生成的带有文档的代码片段"), + "{prompt}" + ); + assert!(!prompt.contains("生成代码遇到错误"), "{prompt}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 1c27b262f..9e463a265 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -16,6 +16,7 @@ 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 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布居中责任唯一:使用 Phaser Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 的直接父容器用普通 block 按需要的宽高确定尺寸,不得在同一个 canvas 父容器上叠加 grid/flex 的 place-items、justify-content、align-items 居中或 margin:auto、translate 居中;若选择用 CSS 居中,则必须把 Phaser autoCenter 设为 NO_CENTER。外围布局仍可用 flex/grid,但同一个 canvas 的定位责任只能有一处。预览偏移先查项目自身的 CSS 与 Phaser 配置,不得用修改 AGC iframe 偏移来掩盖。改完布局后必须在桌面与移动视口以及 resize 后实测 canvas 相对游戏父容器的中心误差不超过 1 CSS px、无溢出,并按项目 scripts 构建 dist 后复验。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 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-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_UNITY_BUILTIN_PLUGIN_GUIDANCE: &str = "Unity 编辑器能力来自客户端内置插件 agc-unity-editor,工具为 agc_unity_execute(Runtime 为 unity.editor.execute)。当前工程是 Unity 时使用该工具执行 C#,先读取实际场景与对象再修改;不安装 UPM 或项目内 MCP,不改写为 Phaser。只支持 Windows x64 Mono Editor;缺少工具时报告客户端内置插件不可用。仅提交 code;主线程同步代码无法硬中止。needs-reconciliation 表示结果待人工核对,禁止自动重发、重启插件或切换项目以绕过阻断。只有真实 completed 回执才可报告成功。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_ENGINE_FREEDOM_GUIDANCE: &str = "三维请求合同:用户要做三维(3D)游戏时,不受“新 Web 游戏固定 Phaser 4.2.1”的约束,由你自行选择三维技术栈(例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎),可以按需新增 npm 依赖,并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;做不到就用回复说明限制与原因。用户明确指定 Cocos、Unity、Godot 等编辑器而当前目录不具备对应工程结构时,仍按既有规则先说明不匹配再动作。"; @@ -69,32 +70,31 @@ const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [ const DIRECT_CODEX_ART_AGENT_ID: &str = "direct-codex-art"; const DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER: &str = "[[AGC_CREATE_PROJECT]]"; -fn direct_codex_game_outputs(root: &Path) -> Vec<(String, &'static str, &'static str)> { +/// 直连 Codex 生成的游戏工程文件 → manifest 登记项。 +/// +/// 这些不是用户素材,而是游戏工程自身的源码/配置(`index.html`、`style.css`、`game.js`、 +/// `package.json`、`package-lock.json`、`vite.config.js`)。它们的 manifest kind 统一是正式成员 +/// `code`:`code` 与旧的 `game-*` / `image` 兜底一样派生 `unclassified`(「待归类」), +/// 不会把工程文件塞进内容栏目,同时写入侧不再产出非 canonical 的 kind。 +fn direct_codex_game_outputs(root: &Path) -> Vec<(String, GameCreationAppAssetKind, &'static str)> { let entry = agent_runtime_game_entry_relative_path(root); let prefix = if entry == AGENT_RUNTIME_GAME_ENTRY_ROOT_PATH { "" } else { "game/" }; + let code = GameCreationAppAssetKind::Code; vec![ - (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", - ), + (entry.to_string(), code, "text/html"), + (format!("{prefix}style.css"), code, "text/css"), + (format!("{prefix}game.js"), code, "text/javascript"), + (format!("{prefix}package.json"), code, "application/json"), ( format!("{prefix}package-lock.json"), - "game-lockfile", + code, "application/json", ), - ( - format!("{prefix}vite.config.js"), - "game-build-config", - "text/javascript", - ), + (format!("{prefix}vite.config.js"), code, "text/javascript"), ] } @@ -271,7 +271,11 @@ pub(crate) fn direct_project_engine(root: &Path) -> DirectProjectEngine { { return DirectProjectEngine::Godot; } - if root.join("ProjectSettings/ProjectVersion.txt").is_file() { + if crate::project::discover_local_unity_project_root(root) + .ok() + .flatten() + .is_some() + { return DirectProjectEngine::Unity; } if direct_project_has_unreal_project_file(root) { @@ -328,7 +332,7 @@ struct DirectTaonierArtAssetIdentity { local_asset_id: String, source_sha256: String, media_type: String, - canonical_asset_kind: String, + canonical_asset_kind: GameCreationAppAssetKind, resource_id: String, asset_object_id: String, canvas_project_id: String, @@ -847,10 +851,20 @@ pub(in crate::agent) fn direct_taonier_regeneration_workflow_retains_stage_ledge if agent_id != "direct-codex-art" { return Ok(false); } + // TODO: `run_id` 是 Direct 重生成阶段槽标识,不是 manifest `asset_kind`;两者必须保持独立。 let (output_path, asset_kind) = match run_id { - "art-spec" => (DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec"), - "game-background" => (DIRECT_CODEX_BACKGROUND_ASSET_PATH, "game-background"), - "art-spritesheet" => (DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "art-spritesheet"), + "art-spec" => ( + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + GameCreationAppAssetKind::IconSpec, + ), + "game-background" => ( + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + GameCreationAppAssetKind::Scene, + ), + "art-spritesheet" => ( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + GameCreationAppAssetKind::IconSpritesheet, + ), _ => return Ok(false), }; if read_direct_taonier_regeneration_workflow_at(root)?.is_none() { @@ -883,9 +897,18 @@ fn write_direct_taonier_regeneration_workflow_at( fn clear_direct_taonier_regeneration_stage_ledgers_at(root: &Path) -> Result<(), String> { for (output_path, asset_kind) in [ - (DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec"), - (DIRECT_CODEX_BACKGROUND_ASSET_PATH, "game-background"), - (DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "art-spritesheet"), + ( + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + GameCreationAppAssetKind::IconSpec, + ), + ( + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + GameCreationAppAssetKind::Scene, + ), + ( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + GameCreationAppAssetKind::IconSpritesheet, + ), ] { let context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; remove_platform_art_generation_runtime_state_at(root, &context.agent_id, &context.run_id)?; @@ -903,9 +926,9 @@ fn direct_taonier_regeneration_stage_ledgers_exist_at(root: &Path) -> bool { fn validate_direct_taonier_retained_stage_ledgers_at(root: &Path) -> Result<(), String> { for (run_id, asset_kind) in [ - ("art-spec", "icon-spec"), - ("game-background", "game-background"), - ("art-spritesheet", "art-spritesheet"), + ("art-spec", GameCreationAppAssetKind::IconSpec), + ("game-background", GameCreationAppAssetKind::Scene), + ("art-spritesheet", GameCreationAppAssetKind::IconSpritesheet), ] { let exists = game_creator_agent_runtime_external_generation_exists(root, "direct-codex-art", run_id); @@ -917,7 +940,8 @@ fn validate_direct_taonier_retained_stage_ledgers_at(root: &Path) -> Result<(), ) .map_err(|error| { format!( - "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法校验陶泥儿 {asset_kind} 阶段账本,已保留现场:{error}" + "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法校验陶泥儿 {} 阶段账本,已保留现场:{error}", + asset_kind.as_str() ) })? } else { @@ -938,7 +962,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at( let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -948,7 +972,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at( let Some(spritesheet) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", Some(&art_spec), @@ -958,7 +982,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at( let context = direct_taonier_art_generation_runtime_context( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, )?; let retained = retained_platform_art_generation_runtime_spritesheet_identity_at( root, @@ -2472,7 +2496,7 @@ fn persist_direct_codex_failure_context( fn direct_taonier_art_generation_runtime_context( root: &Path, output_path: &str, - asset_kind: &str, + asset_kind: GameCreationAppAssetKind, ) -> Result { let project_id = read_manifest(&root.join(".agent/manifest.json"))? .project_id @@ -2501,7 +2525,7 @@ fn direct_taonier_art_generation_runtime_context( fn direct_taonier_art_asset_identity( root: &Path, expected_path: &str, - expected_kind: &str, + expected_kind: GameCreationAppAssetKind, expected_generation_route: &str, expected_generation_kind: &str, expected_reference_source: Option<&DirectTaonierArtAssetIdentity>, @@ -2535,7 +2559,7 @@ fn direct_taonier_art_asset_identity( local_asset_id: asset.id.clone(), source_sha256: validated.content_sha256, media_type: asset.media_type.clone(), - canonical_asset_kind: asset.kind.clone(), + canonical_asset_kind: asset.kind, resource_id: asset .source .resource_id @@ -2618,7 +2642,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool { let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -2628,7 +2652,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool { let Some(background) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "/api/external/v1/editor/images/generations", "spec", Some(&art_spec), @@ -2643,7 +2667,7 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -2656,7 +2680,7 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { let Some(spritesheet) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", Some(&art_spec), @@ -2707,7 +2731,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -2749,7 +2773,10 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { return Vec::new(); }; if assets.next().is_some() - || asset.kind != "art-spritesheet-slice" + || asset.kind != GameCreationAppAssetKind::Icon + || !asset + .local_path + .starts_with("assets/art-spritesheet-slices/") || asset.media_type != "image/png" || asset.source.kind != GameCreationAppAssetSourceKind::Canvas || asset.source.generation_route.as_deref() @@ -2829,15 +2856,17 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .into_iter() .filter(|asset| { matches!( - asset.kind.as_str(), - "art-spritesheet" | "art-spritesheet-slice" | "game-background" + asset.kind, + GameCreationAppAssetKind::IconSpritesheet + | GameCreationAppAssetKind::Icon + | GameCreationAppAssetKind::Scene ) && asset.media_type == "image/png" && asset.source.kind == GameCreationAppAssetSourceKind::Canvas && asset.local_path.starts_with("assets/") // Canonical slices are admitted above only after the // full slice manifest/receipt/content validation. Do // not let this generic manifest fallback bypass it. - && !(asset.kind == "art-spritesheet-slice" + && !(asset.kind == GameCreationAppAssetKind::Icon && asset .local_path .starts_with("assets/art-spritesheet-slices/")) @@ -2868,7 +2897,7 @@ fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { .iter() .filter(|asset| { asset.media_type.starts_with("image/") - && asset.kind != "icon-spec" + && asset.kind != GameCreationAppAssetKind::IconSpec && asset.source.kind == GameCreationAppAssetSourceKind::Canvas && asset .source @@ -2880,7 +2909,7 @@ fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { // manifest/receipt/content validation. This generic fallback // must not re-admit a slice whose bytes no longer match the // registered receipt. - && !(asset.kind == "art-spritesheet-slice" + && !(asset.kind == GameCreationAppAssetKind::Icon && asset .local_path .starts_with("assets/art-spritesheet-slices/")) @@ -3059,7 +3088,7 @@ fn snapshot_direct_spritesheet_recovery_manifest_for_install( let current_art_spec = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -3078,7 +3107,7 @@ fn register_direct_recovered_spritesheet_at( register_local_asset_at( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "image/png", "platform-art-recovery", source, @@ -3115,7 +3144,7 @@ fn install_direct_recovered_spritesheet_binding_at( ®istered_asset.id, content_sha256, "image/png", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet.as_str(), )?; let binding = new_external_editor_resource_binding( project_id, @@ -3162,7 +3191,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( &source_asset.id, &format!("{:x}", Sha256::digest(&source_bytes)), &source_asset.media_type, - &source_asset.kind, + source_asset.kind.as_str(), )?; let principal = external_editor_binding_principal(&access)?; let Some(project_binding) = @@ -3190,7 +3219,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( local_asset_id: art_spec.local_asset_id.clone(), source_sha256: art_spec.source_sha256.clone(), media_type: art_spec.media_type.clone(), - canonical_asset_kind: art_spec.canonical_asset_kind.clone(), + canonical_asset_kind: art_spec.canonical_asset_kind, resource_id: remote_spec_resource_id, asset_object_id: spec_binding.asset_object_id, canvas_project_id: project_binding.remote_project_id, @@ -3379,24 +3408,29 @@ async fn generate_direct_taonier_art_asset_at( prompt: &str, output_path: &str, aspect_ratio: &str, - asset_kind: &str, + asset_kind: GameCreationAppAssetKind, asset_label: &str, require_slices: bool, retain_runtime_state: bool, ) -> Result { + let parsed_asset_kind = asset_kind; + if parsed_asset_kind == GameCreationAppAssetKind::Unknown { + return Err("直连美术生成的资源 kind 无法解析".to_string()); + } let options = PlatformArtAssetGenerationOptions { output_path: Some(output_path.to_string()), aspect_ratio: aspect_ratio.to_string(), image_size: "1K".to_string(), - asset_kind: asset_kind.to_string(), + asset_kind: parsed_asset_kind, asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), // 标准美术包必须产出四张 canonical 切片:连通域模式下显式声明目标数量, // 让平台要么给出四张,要么以可执行的 422 说明实际识别数量。 - slice_count: (asset_kind == "art-spritesheet").then_some(4), + slice_count: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet).then_some(4), // 切分模式没有默认值:陶泥儿标准美术包按自由排布生成核心图集,因此只在 // art-spritesheet 阶段显式声明连通域切分。 - slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()), + slice_mode: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet) + .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), @@ -3435,7 +3469,7 @@ async fn generate_direct_taonier_art_asset_at( let art_spec = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -3595,7 +3629,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -3606,7 +3640,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( direct_taonier_art_asset_identity( root, DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "/api/external/v1/editor/images/generations", "spec", Some(art_spec), @@ -3622,7 +3656,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( prompt, DIRECT_CODEX_ART_SPEC_ASSET_PATH, "1:1", - "icon-spec", + GameCreationAppAssetKind::IconSpec, "陶泥儿首版游戏视觉规范图", false, mode.regenerates_existing(), @@ -3672,7 +3706,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -3690,7 +3724,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( || direct_taonier_art_asset_identity( root, DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "/api/external/v1/editor/images/generations", "spec", Some(&art_spec), @@ -3703,7 +3737,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( prompt, DIRECT_CODEX_BACKGROUND_ASSET_PATH, "16:9", - "game-background", + GameCreationAppAssetKind::Scene, "陶泥儿首版游戏场景背景", false, mode.regenerates_existing(), @@ -3820,7 +3854,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( prompt, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "1:1", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "陶泥儿首版核心游戏美术图集", true, mode.regenerates_existing(), @@ -4430,7 +4464,7 @@ fn sync_direct_codex_project_file_projection_at( register_local_asset_at( root, &local_path, - "game-source", + GameCreationAppAssetKind::Code, media_type, "direct-codex", direct_codex_generated_source(), @@ -4560,6 +4594,7 @@ fn build_direct_codex_system_prompt_with_search( DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), DIRECT_ENGINE_FREEDOM_GUIDANCE.to_string(), DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE.to_string(), + DIRECT_UNITY_BUILTIN_PLUGIN_GUIDANCE.to_string(), DIRECT_COCOS_CAPABILITY_GUIDE.to_string(), "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。工具返回 isError、构建失败、验证失败或试玩异常时,把错误当作调试上下文,读取当前项目、修复真实文件并重跑失败步骤,不要直接结束或伪造成功;鉴权、权限、余额、身份、历史、传输断开和操作状态不确定等安全错误才停止。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), format!("提示词与技能:{skill_index}"), @@ -6101,6 +6136,8 @@ mod tests { .expect("godot project"); } "unity" => { + std::fs::create_dir_all(root.path().join("Assets")).expect("unity assets"); + std::fs::create_dir_all(root.path().join("Packages")).expect("unity packages"); std::fs::create_dir_all(root.path().join("ProjectSettings")) .expect("unity settings"); std::fs::write( @@ -7141,7 +7178,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-before-crash", Vec::new(), &replacement_spec, @@ -7239,7 +7276,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "strict-crash-spec", Vec::new(), &new_spec, @@ -7253,7 +7290,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "strict-crash-background", vec!["strict-crash-spec".to_string()], &new_background, @@ -7389,7 +7426,7 @@ mod tests { let context = direct_taonier_art_generation_runtime_context( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ) .expect("direct stage context"); let request_body = serde_json::json!({ @@ -7469,7 +7506,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-before-crash", Vec::new(), &tiny_replacement_png(64, 96, 224), @@ -7552,7 +7589,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "unknown-stage-replacement", Vec::new(), &tiny_replacement_png(224, 64, 32), @@ -8010,7 +8047,7 @@ mod tests { local_asset_id: "local-art-spec".to_string(), source_sha256: "a".repeat(64), media_type: "image/png".to_string(), - canonical_asset_kind: "icon-spec".to_string(), + canonical_asset_kind: GameCreationAppAssetKind::IconSpec, resource_id: "taonier-resource-icon-spec".to_string(), asset_object_id: "taonier-object-icon-spec".to_string(), canvas_project_id: "taonier-project".to_string(), @@ -8151,7 +8188,7 @@ mod tests { let art_spec = direct_taonier_art_asset_identity( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -8166,7 +8203,7 @@ mod tests { let concurrent = register_local_asset_at( root.path(), concurrent_path, - "illustration", + GameCreationAppAssetKind::Image, "image/png", "uploaded", GameCreationAppAssetSource { @@ -8199,7 +8236,7 @@ mod tests { let recovery = register_local_asset_at( root.path(), recovery_path, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "image/png", "platform-art-recovery", GameCreationAppAssetSource { @@ -8247,7 +8284,7 @@ mod tests { let registered = register_local_asset_at( root.path(), local_path, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "image/png", "platform-art-recovery", GameCreationAppAssetSource { @@ -8364,14 +8401,18 @@ mod tests { })); } - fn register_direct_taonier_art_asset_fixture(root: &Path, local_path: &str, kind: &str) { + fn register_direct_taonier_art_asset_fixture( + root: &Path, + local_path: &str, + kind: GameCreationAppAssetKind, + ) { let (generation_route, generation_kind, reference_resource_ids) = match kind { - "art-spritesheet" => ( + GameCreationAppAssetKind::IconSpritesheet => ( "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", vec!["taonier-resource-icon-spec".to_string()], ), - "game-background" => ( + GameCreationAppAssetKind::Scene => ( "/api/external/v1/editor/images/generations", "spec", vec!["taonier-resource-icon-spec".to_string()], @@ -8382,6 +8423,14 @@ mod tests { Vec::new(), ), }; + // 平台侧资源/对象/任务 id 按生成阶段命名(art-spec / game-background / + // art-spritesheet),与 manifest kind 是两套词汇;kind 收紧成枚举后不能让这些 + // fixture 标识跟着改名。 + let identity_slug = match kind { + GameCreationAppAssetKind::IconSpritesheet => "art-spritesheet", + GameCreationAppAssetKind::Scene => "game-background", + _ => kind.as_str(), + }; let mut png_bytes = Vec::new(); let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( 1, @@ -8404,9 +8453,9 @@ mod tests { GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, canvas_project_id: Some("taonier-project".to_string()), - resource_id: Some(format!("taonier-resource-{kind}")), - asset_object_id: Some(format!("taonier-object-{kind}")), - task_id: Some(format!("taonier-task-{kind}")), + resource_id: Some(format!("taonier-resource-{identity_slug}")), + asset_object_id: Some(format!("taonier-object-{identity_slug}")), + task_id: Some(format!("taonier-task-{identity_slug}")), prompt: None, model: Some("gpt-image-2".to_string()), generation_route: Some(generation_route.to_string()), @@ -8422,7 +8471,7 @@ mod tests { register_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ); let main_bytes = std::fs::read(root.join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH)) .expect("spritesheet bytes"); @@ -8499,7 +8548,7 @@ mod tests { register_local_asset_at( root, path, - "art-spritesheet-slice", + GameCreationAppAssetKind::Icon, "image/png", "platform-art", GameCreationAppAssetSource { @@ -8531,7 +8580,7 @@ mod tests { let context = direct_taonier_art_generation_runtime_context( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ) .expect("spritesheet runtime context"); let request_body = serde_json::json!({ @@ -8611,7 +8660,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, &spritesheet_resource_id, vec![art_spec_resource_id.to_string()], &spritesheet_bytes, @@ -8698,19 +8747,20 @@ mod tests { fn replace_direct_taonier_art_asset_fixture( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, resource_id: &str, reference_resource_ids: Vec, bytes: &[u8], ) { - let (generation_route, generation_kind) = if kind == "art-spritesheet" { - ( - "/api/external/v1/editor/icon-spritesheets/generations", - "icon-spritesheet", - ) - } else { - ("/api/external/v1/editor/images/generations", "spec") - }; + let (generation_route, generation_kind) = + if kind == GameCreationAppAssetKind::IconSpritesheet { + ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + ) + } else { + ("/api/external/v1/editor/images/generations", "spec") + }; std::fs::write(root.join(local_path), bytes).expect("replace fixture bytes"); register_local_asset_at( root, @@ -8739,12 +8789,12 @@ mod tests { register_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ); register_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, ); } @@ -9039,7 +9089,11 @@ mod tests { init_local_game_project_at(root.path(), "direct-independent-image", "独立平台图片") .expect("init project"); std::fs::create_dir_all(root.path().join("assets")).expect("assets dir"); - register_direct_taonier_art_asset_fixture(root.path(), "assets/neon-mine.png", "image"); + register_direct_taonier_art_asset_fixture( + root.path(), + "assets/neon-mine.png", + GameCreationAppAssetKind::Image, + ); std::fs::write(root.path().join("game/index.html"), "").expect("index"); std::fs::write(root.path().join("game/style.css"), "body {} ").expect("style"); std::fs::write( @@ -9247,7 +9301,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-spec", Vec::new(), &tiny_replacement_png(32, 64, 192), @@ -9258,7 +9312,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "replacement-background", vec!["replacement-spec".to_string()], &tiny_replacement_png(16, 128, 224), @@ -9346,7 +9400,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "durable-replacement-spec", Vec::new(), &tiny_replacement_png(32, 64, 192), @@ -9359,7 +9413,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "durable-replacement-background", vec!["durable-replacement-spec".to_string()], &tiny_replacement_png(16, 128, 224), @@ -9444,7 +9498,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "uncertain-committed-spec", Vec::new(), &replacement, @@ -9504,7 +9558,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "uncertain-committed-spritesheet", vec!["taonier-resource-icon-spec".to_string()], &replacement, @@ -9560,7 +9614,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "late-committed-spec", Vec::new(), &tiny_replacement_png(32, 64, 192), @@ -9614,7 +9668,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-spec-before-background", Vec::new(), &tiny_replacement_png(32, 64, 192), @@ -9633,7 +9687,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "late-committed-background", vec!["replacement-spec-before-background".to_string()], &tiny_replacement_png(16, 128, 224), @@ -9678,7 +9732,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-spec", Vec::new(), &replacement_spec, @@ -9689,7 +9743,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "replacement-background", vec!["replacement-spec".to_string()], &replacement_background, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index c74690439..c418832f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -27,7 +27,6 @@ const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format= const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS: usize = 80; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PAGE_SIZE: usize = 100; -const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_CALLS_PER_TURN: usize = 4; const DIRECT_TOOL_BRIDGE_MAX_ACCOUNT_ASSET_ID_CHARS: usize = 512; const DIRECT_TOOL_BRIDGE_MAX_LOCAL_ASSET_PATH_CHARS: usize = 512; @@ -68,7 +67,6 @@ struct DirectToolBridgeActiveTurnAuthorization { turn_id: String, brief_sha256: Option, completed_result: Option, - resource_request_ids: BTreeMap, } enum DirectToolBridgeRegenerationCall { @@ -207,7 +205,6 @@ impl DirectToolBridgeState { turn_id: turn_id.clone(), brief_sha256: None, completed_result: None, - resource_request_ids: BTreeMap::new(), }); Ok(DirectToolBridgeTurnGuard { state: Arc::clone(self), @@ -637,30 +634,17 @@ impl DirectToolBridgeState { Ok(()) } - fn resource_request_ids(&self, request_fingerprint: &str) -> Result<(String, String), String> { - let mut authorization = self + /// 取当前回合身份,媒体资源请求按回合身份与请求指纹确定性派生 operation/idempotency id。 + fn active_resource_turn_id(&self) -> Result { + let authorization = self .turn_authorization .lock() .map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?; - let active = authorization + authorization .active - .as_mut() - .ok_or_else(|| "当前没有客户端签发的资源生成回合身份".to_string())?; - if let Some(ids) = active.resource_request_ids.get(request_fingerprint) { - return Ok(ids.clone()); - } - if active.resource_request_ids.len() >= DIRECT_TOOL_BRIDGE_MAX_RESOURCE_CALLS_PER_TURN { - return Err("单个用户回合最多只能创建四项媒体资源请求".to_string()); - } - let operation_id = - direct_resource_request_uuid(&active.turn_id, "operation", request_fingerprint); - let idempotency_key = - direct_resource_request_uuid(&active.turn_id, "idempotency", request_fingerprint); - active.resource_request_ids.insert( - request_fingerprint.to_string(), - (operation_id.clone(), idempotency_key.clone()), - ); - Ok((operation_id, idempotency_key)) + .as_ref() + .map(|active| active.turn_id.clone()) + .ok_or_else(|| "当前没有客户端签发的资源生成回合身份".to_string()) } } @@ -1213,9 +1197,9 @@ fn bridge_registered_resource( "mediaType": asset.media_type, // Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory` // (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。 - // 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、 + // 直接透传落盘 `asset.category` 会让 UI 设计资产在 UI 显示「UI 交互」、 // 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。 - "category": game_creation_app_asset_effective_category(&asset.kind, asset.category), + "category": game_creation_app_asset_effective_category(asset.kind, asset.category), "tags": asset.tags, "canvasProjectId": asset.source.canvas_project_id, "resourceId": asset.source.resource_id, @@ -1256,7 +1240,9 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { arguments, "kind", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS, - )?; + )? + .map(|kind| bridge_asset_list_kind_filter(&kind)) + .transpose()?; let asset_id = bridge_optional_bounded_string( arguments, "assetId", @@ -1276,7 +1262,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { let mut assets = manifest .assets .iter() - .filter(|asset| kind.as_ref().is_none_or(|kind| asset.kind == *kind)) + .filter(|asset| kind.is_none_or(|kind| asset.kind == kind)) .filter(|asset| { asset_id .as_ref() @@ -1342,6 +1328,24 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { } } +/// `asset.list` 的 `kind` 过滤:只接受 canonical 值,认不出的值直接报错。 +/// +/// 以前认不出的值会收口成 `unknown` 再参与等值过滤,结果是**静默返回空列表**:调用方(模型) +/// 会以为项目里没有这类资产,而不是"你传的 kind 不合法",于是继续按错误前提往下走。 +/// `unknown` 本身仍是合法输入(确有 kind 未知的资产),只拒绝"既不是 canonical、也不是字面 +/// `unknown`"的原值。 +fn bridge_asset_list_kind_filter(raw: &str) -> Result { + let kind = GameCreationAppAssetKind::parse_with_context(raw, "asset.list.kind"); + if kind == GameCreationAppAssetKind::Unknown + && raw.trim() != GameCreationAppAssetKind::Unknown.as_str() + { + return Err(format!( + "kind 不是已知的 manifest 资源 kind:{raw};请改用 canonical kind(如 image、scene、character、icon、icon-spritesheet、character-animation、audio、video、document)" + )); + } + Ok(kind) +} + fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) { let extension = Path::new(path) .extension() @@ -1890,8 +1894,11 @@ async fn bridge_create_or_derive_resource( )) .await? } else { - let (operation_id, idempotency_key) = - state.resource_request_ids(&request_fingerprint)?; + let turn_id = state.active_resource_turn_id()?; + let operation_id = + direct_resource_request_uuid(&turn_id, "operation", &request_fingerprint); + let idempotency_key = + direct_resource_request_uuid(&turn_id, "idempotency", &request_fingerprint); let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision; let source_resource_id = source_asset .as_ref() @@ -1909,7 +1916,7 @@ async fn bridge_create_or_derive_resource( source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()), source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()), source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()), - source_subtype: source_asset.as_ref().map(|asset| asset.kind.clone()), + source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()), producer_task_id: source_asset .as_ref() .and_then(|asset| asset.source.task_id.clone()), @@ -2001,7 +2008,11 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val }) .await? } else { - let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; + // id 按回合身份与请求指纹确定性派生,同指纹重试与 pending 对账语义不变。 + let turn_id = state.active_resource_turn_id()?; + let operation_id = direct_resource_request_uuid(&turn_id, "operation", &fingerprint); + let idempotency_key = + direct_resource_request_uuid(&turn_id, "idempotency", &fingerprint); let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision; let request = DeriveLocalProjectResourceInput { project_path: state.root.to_string_lossy().into_owned(), @@ -2015,7 +2026,7 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val source_asset_id: Some(source_asset_id.clone()), source_path: Some(source_asset.local_path.clone()), source_media_type: Some(source_asset.media_type.clone()), - source_subtype: Some(source_asset.kind.clone()), + source_subtype: Some(source_asset.kind.to_string()), producer_task_id: source_asset.source.task_id.clone(), source_version_id: None, prompt: "去除背景".to_string(), @@ -2183,35 +2194,37 @@ async fn bridge_prepare_game_art(state: &DirectToolBridgeState, arguments: &Valu result } -fn bridge_image_generation_kind(arguments: &Value) -> Result { +fn bridge_image_generation_kind(arguments: &Value) -> Result { let kind = arguments .get("kind") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("image"); - normalize_platform_art_asset_generation_kind(kind) - .map(str::to_string) - .ok_or_else(|| { - format!( - "工具参数 kind 只允许 {}", - PLATFORM_ART_ASSET_GENERATION_KINDS.join("、") - ) - }) + normalize_platform_art_asset_generation_kind(kind).ok_or_else(|| { + format!( + "工具参数 kind 只允许 {}", + PLATFORM_ART_ASSET_GENERATION_KINDS + .iter() + .map(|kind| kind.as_str()) + .collect::>() + .join("、") + ) + }) } /// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。 fn validate_generate_image_slice_declaration( - kind: &str, + kind: GameCreationAppAssetKind, slice_mode: Option<&str>, grid_x: Option, grid_y: Option, slice_count: Option, ) -> Result<(), String> { - if kind == "art-spritesheet" { + if kind == GameCreationAppAssetKind::IconSpritesheet { if slice_mode.is_none() { return Err( - "kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components" + "kind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components" .to_string(), ); } @@ -2224,19 +2237,19 @@ fn validate_generate_image_slice_declaration( } if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() { return Err(format!( - "工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=art-spritesheet 生效,当前 kind={kind}" + "工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=icon-spritesheet 生效,当前 kind={kind}" )); } Ok(()) } -/// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为 +/// 抠图纯色背景只服务 character 与 icon-spritesheet 链路;格式校验收口为 /// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为 /// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝, /// 避免服务端静默忽略造成“已生效”的误解。 fn normalize_generate_image_screen_color( arguments: &Value, - kind: &str, + kind: GameCreationAppAssetKind, ) -> Result, String> { let Some(value) = arguments.get("screenColor") else { return Ok(None); @@ -2244,9 +2257,12 @@ fn normalize_generate_image_screen_color( if value.is_null() { return Ok(None); } - if !matches!(kind, "character" | "art-spritesheet") { + if !matches!( + kind, + GameCreationAppAssetKind::Character | GameCreationAppAssetKind::IconSpritesheet + ) { return Err(format!( - "工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}" + "工具参数 screenColor 仅对 kind=character 和 kind=icon-spritesheet 生效,当前 kind={kind}" )); } let raw = value @@ -2373,18 +2389,18 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) }) .transpose()?; validate_generate_image_slice_declaration( - kind.as_str(), + kind, slice_mode.as_deref(), grid_x, grid_y, slice_count, )?; - let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?; + let screen_color = normalize_generate_image_screen_color(arguments, kind)?; let options = PlatformArtAssetGenerationOptions { output_path, aspect_ratio, image_size, - asset_kind: kind.clone(), + asset_kind: kind, asset_label: asset_name.clone(), replace_existing: false, slice_count, @@ -2592,6 +2608,51 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str) } } +#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] +async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { + let prepared = (|| { + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("当前 Unity 插件不可用".to_string()); + } + enforce_project_permission_policy(&state.root, "unity.editor.execute")?; + bridge_reject_unknown_fields(arguments, &["code"])?; + let code = arguments + .get("code") + .and_then(Value::as_str) + .ok_or_else(|| "code 必须是 C# 代码".to_string())?; + if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') { + return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string()); + } + Ok(code.to_string()) + })(); + let code = match prepared { + Ok(code) => code, + Err(error) => { + return bridge_tool_result( + redact_agent_runtime_error(&state.root, &error, 480), + Vec::new(), + true, + ) + } + }; + let root = state.root.clone(); + let result = tokio::task::spawn_blocking(move || { + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("当前 Unity 插件不可用".to_string()); + } + crate::editor_adapters::execute_unity_editor_code(&root, &code) + }) + .await; + match result { + Ok(Ok(response)) => { + let failed = response["ok"] != true || response["status"] != "completed"; + bridge_tool_result(redact_agent_runtime_error(&state.root, &response.to_string(), 32_000), Vec::new(), failed) + } + Ok(Err(error)) => bridge_tool_result(redact_agent_runtime_error(&state.root, &error, 480), Vec::new(), true), + Err(_) => bridge_tool_result(json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"error":"Unity 执行任务异常,请人工核对结果"}).to_string(), Vec::new(), true), + } +} + #[cfg(all(windows, feature = "cocos-editor-execute"))] async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { bridge_cocos_call(state, arguments, None).await @@ -2603,10 +2664,12 @@ async fn bridge_cocos_call( arguments: &Value, operation: Option<&str>, ) -> Value { - if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&state.root) { + if !crate::builtin_plugins::is_enabled(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) { + return bridge_tool_result("Cocos 编辑器插件已禁用".to_string(), Vec::new(), true); + } + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return bridge_tool_result( - "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用,agc_cocos_execute 不可用" - .to_string(), + "当前 Cocos 插件不可用,agc_cocos_execute 不可用".to_string(), Vec::new(), true, ); @@ -2671,9 +2734,9 @@ async fn bridge_cocos_call( // validated Inspector/pipe bridge. It does not mutate AGC's project // files or manifest, so it must not wait on `.agent/project.lock`. // File-writing tools keep their own project lock separately. - if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&root) { + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return Err(cocos_editor_bridge::BridgeError::InvalidInput( - "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(), + "当前 Cocos 插件不可用".to_string(), )); } cocos_editor_bridge::execute_cocos_editor_code_for_project( @@ -2776,7 +2839,7 @@ async fn handle_direct_tool_bridge( let result = match request.tool.as_str() { // 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。 "builtin.plugins.tools" => bridge_tool_result( - json!({"tools": crate::builtin_plugins::available_agent_tools_for_project(&state.root)}).to_string(), + json!({"tools": crate::builtin_plugins::available_agent_tools()}).to_string(), Vec::new(), false, ), @@ -2789,6 +2852,8 @@ async fn handle_direct_tool_bridge( "agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments), #[cfg(all(windows, feature = "cocos-editor-execute"))] "agc_cocos_execute" => bridge_cocos_execute(&state, &request.arguments).await, + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + "agc_unity_execute" => bridge_unity_execute(&state, &request.arguments).await, #[cfg(all(windows, feature = "cocos-editor-execute"))] operation if cocos_editor_bridge::is_cocos_operation(operation) => { bridge_cocos_call(&state, &request.arguments, Some(operation)).await @@ -2873,14 +2938,19 @@ pub(crate) async fn start_direct_tool_bridge( mod tests { #[test] fn generate_image_slice_declaration_is_explicit_and_self_consistent() { - let missing = - validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None) - .expect_err("art-spritesheet without sliceMode must fail closed"); + let missing = validate_generate_image_slice_declaration( + GameCreationAppAssetKind::IconSpritesheet, + None, + None, + None, + None, + ) + .expect_err("icon-spritesheet without sliceMode must fail closed"); assert!(missing.contains("没有默认值"), "{missing}"); assert!(missing.contains("connected-components"), "{missing}"); assert!(validate_generate_image_slice_declaration( - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, Some("connected-components"), None, None, @@ -2888,7 +2958,7 @@ mod tests { ) .is_ok()); assert!(validate_generate_image_slice_declaration( - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, Some("grid"), Some(3), Some(2), @@ -2896,7 +2966,7 @@ mod tests { ) .is_ok()); let grid_with_count = validate_generate_image_slice_declaration( - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, Some("grid"), Some(2), Some(2), @@ -2906,58 +2976,80 @@ mod tests { assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}"); let wrong_kind = validate_generate_image_slice_declaration( - "image", + GameCreationAppAssetKind::Image, Some("connected-components"), None, None, None, ) - .expect_err("slice declaration must stay scoped to art-spritesheet"); + .expect_err("slice declaration must stay scoped to icon-spritesheet"); assert!( - wrong_kind.contains("仅对 kind=art-spritesheet 生效"), + wrong_kind.contains("仅对 kind=icon-spritesheet 生效"), "{wrong_kind}" ); - let wrong_kind_count = - validate_generate_image_slice_declaration("image", None, None, None, Some(8)) - .expect_err("sliceCount-only violation must be rejected"); + let wrong_kind_count = validate_generate_image_slice_declaration( + GameCreationAppAssetKind::Image, + None, + None, + None, + Some(8), + ) + .expect_err("sliceCount-only violation must be rejected"); assert!( wrong_kind_count.contains("sliceCount"), "sliceCount-only violation must name sliceCount: {wrong_kind_count}" ); - assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok()); + assert!(validate_generate_image_slice_declaration( + GameCreationAppAssetKind::Image, + None, + None, + None, + None + ) + .is_ok()); } #[test] fn generate_image_screen_color_is_normalized_and_kind_gated() { // 省略与显式 null 等价,且不触发 kind 门禁。 assert_eq!( - normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"), + normalize_generate_image_screen_color(&json!({}), GameCreationAppAssetKind::Image) + .expect("omitted"), None ); assert_eq!( - normalize_generate_image_screen_color(&json!({"screenColor": null}), "image") - .expect("null"), + normalize_generate_image_screen_color( + &json!({"screenColor": null}), + GameCreationAppAssetKind::Image + ) + .expect("null"), None ); // auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。 for raw in ["auto", "AUTO", " auto ", ""] { assert_eq!( - normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character") - .expect("auto variants"), + normalize_generate_image_screen_color( + &json!({"screenColor": raw}), + GameCreationAppAssetKind::Character + ) + .expect("auto variants"), None, "{raw}" ); } // hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。 assert_eq!( - normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character") - .expect("lowercase hex"), + normalize_generate_image_screen_color( + &json!({"screenColor": "#cfefff"}), + GameCreationAppAssetKind::Character + ) + .expect("lowercase hex"), Some("#CFEFFF".to_string()) ); assert_eq!( normalize_generate_image_screen_color( &json!({"screenColor": " #A0BBA0 "}), - "art-spritesheet" + GameCreationAppAssetKind::IconSpritesheet ) .expect("padded hex"), Some("#A0BBA0".to_string()) @@ -2965,19 +3057,24 @@ mod tests { // 非 auto/非 hex、非字符串一律拒绝。 for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] { assert!( - normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character") - .is_err(), + normalize_generate_image_screen_color( + &json!({"screenColor": bad}), + GameCreationAppAssetKind::Character + ) + .is_err(), "{bad}" ); } // 其它 kind 携带该字段直接拒绝,即使取值合法。 - let gated = - normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image") - .expect_err("screenColor must stay scoped to character/art-spritesheet"); + let gated = normalize_generate_image_screen_color( + &json!({"screenColor": "#CFEFFF"}), + GameCreationAppAssetKind::Image, + ) + .expect_err("screenColor must stay scoped to character/icon-spritesheet"); assert!(gated.contains("kind=character"), "{gated}"); assert!(normalize_generate_image_screen_color( &json!({"screenColor": "auto"}), - "ui-prototype" + GameCreationAppAssetKind::UiDesign ) .is_err()); } @@ -3746,12 +3843,14 @@ mod tests { fn bridge_art_resource_exposes_only_the_safe_identity_projection() { let asset = GameCreationAppAssetManifestEntry { id: "local-art-1".to_string(), - kind: "art-spritesheet-slice".to_string(), + kind: GameCreationAppAssetKind::Icon, media_type: "image/png".to_string(), local_path: "assets/art-spritesheet-slices/player.png".to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"), + category: game_creation_app_asset_category_for_kind( + GameCreationAppAssetKind::IconSpritesheet, + ), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, @@ -3803,7 +3902,7 @@ mod tests { fn bridge_registered_resource_exposes_formal_sequence_without_private_generation_fields() { let asset = GameCreationAppAssetManifestEntry { id: "animation-1".to_string(), - kind: "character-animation".to_string(), + kind: GameCreationAppAssetKind::CharacterAnimation, media_type: "video/mp4".to_string(), local_path: "assets/edits/animation.mp4".to_string(), image_sequence_frames: Some(vec![ @@ -3816,7 +3915,9 @@ mod tests { }, ]), image_sequence_duration_ms: Some(4_000), - category: game_creation_app_asset_category_for_kind("character-animation"), + category: game_creation_app_asset_category_for_kind( + GameCreationAppAssetKind::CharacterAnimation, + ), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, @@ -3854,7 +3955,7 @@ mod tests { fn bridge_registered_resource_keeps_explicit_manifest_classification() { let asset = GameCreationAppAssetManifestEntry { id: "spec-1".to_string(), - kind: "spec".to_string(), + kind: GameCreationAppAssetKind::Spec, media_type: "application/json".to_string(), local_path: "assets/specs/hero.json".to_string(), image_sequence_frames: None, @@ -3890,13 +3991,13 @@ mod tests { /// /// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈), /// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘 - /// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到 - /// `unclassified`——真机 122 条资产里有 55 条这样分叉。 + /// `category` 会让 `ui-design` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到 + /// `unclassified`,两侧分类口径必须一致。 #[test] fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() { let asset = GameCreationAppAssetManifestEntry { - id: "ui-1".to_string(), - kind: "ui".to_string(), + id: "ui-design-1".to_string(), + kind: GameCreationAppAssetKind::UiDesign, media_type: "application/json".to_string(), local_path: "assets/UI 设计 1.json".to_string(), image_sequence_frames: None, @@ -3919,7 +4020,7 @@ mod tests { }; let projection = bridge_registered_resource(&asset, false); - assert_eq!(projection["kind"], "ui"); + assert_eq!(projection["kind"], "ui-design"); assert_eq!(projection["category"], "ui-interaction"); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 47820340a..f90209a70 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -75,7 +75,14 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option async fn direct_tools_mcp_specs() -> Value { let mut cocos_editor_available = false; - if cfg!(all(windows, feature = "cocos-editor-execute")) { + let mut unity_editor_available = false; + if cfg!(all(windows, feature = "cocos-editor-execute")) + || cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )) + { // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。 if let Ok(result) = tokio::time::timeout( std::time::Duration::from_secs(5), @@ -96,10 +103,22 @@ async fn direct_tools_mcp_specs() -> Value { .iter() .any(|tool| tool == crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME) }); + unity_editor_available = availability + .as_ref() + .and_then(|v| v["tools"].as_array()) + .is_some_and(|tools| { + tools + .iter() + .any(|tool| tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME) + }); } } } - direct_tools_mcp_specs_for(controlled_web_search_enabled(), cocos_editor_available) + direct_tools_mcp_specs_for_plugins( + controlled_web_search_enabled(), + cocos_editor_available, + unity_editor_available, + ) } /// 工具 kind(wire 值)对应的提示词上限。 @@ -128,7 +147,16 @@ fn resource_tool_prompt_schema_max_chars() -> usize { .unwrap_or(0) } -fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value { +#[cfg(test)] +fn direct_tools_mcp_specs_for(controlled_web_search: bool, cocos_editor_available: bool) -> Value { + direct_tools_mcp_specs_for_plugins(controlled_web_search, cocos_editor_available, false) +} + +fn direct_tools_mcp_specs_for_plugins( + controlled_web_search: bool, + _cocos_editor_available: bool, + _unity_editor_available: bool, +) -> Value { let tools = vec![ json!({ "name": "client.session.info", @@ -247,7 +275,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "enum": PLATFORM_ART_ASSET_GENERATION_KINDS, "default": "image", - "description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图" + "description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图" }, "aspectRatio": { "type": "string", @@ -273,7 +301,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "sliceMode": { "type": "string", "enum": ["connected-components", "grid"], - "description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择" + "description": "仅 kind=icon-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择" }, "gridX": { "type": "integer", @@ -291,11 +319,11 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "integer", "minimum": 1, "maximum": 256, - "description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别" + "description": "只与 kind=icon-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别" }, "screenColor": { "type": "string", - "description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近" + "description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=icon-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近" } }, "required": ["prompt"], @@ -341,7 +369,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "minLength": 1, "maxLength": 80, - "description": "可选的 manifest 资源 kind 精确过滤,例如 video、character-animation、sound-effect、background-music 或 art-spritesheet-slice" + "description": "可选的 manifest 资源 kind 精确过滤,必须是 canonical kind(例如 video、character-animation、sound-effect、background-music、icon-spritesheet);传非 canonical 值会被拒绝并报错,不会静默返回空列表" }, "assetId": { "type": "string", @@ -568,6 +596,14 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab .cloned(), ); } + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + if _unity_editor_available { + tools.push(json!({ + "name": "agc_unity_execute", + "description": "在当前项目已打开的 Windows x64 Unity Mono Editor 执行 C#,可使用 return 返回值。仅提交 code;宿主绑定项目及进程。needs-reconciliation 或超时后禁止自动重发。", + "inputSchema": {"type":"object", "properties":{"code":{"type":"string", "minLength":1, "maxLength":131072}}, "required":["code"], "additionalProperties":false} + })); + } if controlled_web_search { tools.push(json!({ "name": "agc_web_search", @@ -655,6 +691,24 @@ async fn call_agc_cocos_execute(arguments: &Value) -> Value { call_client_tool_bridge("agc_cocos_execute", arguments).await } +#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] +async fn call_agc_unity_execute(arguments: &Value) -> Value { + let validated = validate_tool_object_fields(arguments, &["code"]).and_then(|()| { + let code = arguments + .get("code") + .and_then(Value::as_str) + .ok_or_else(|| "code 必须是 C# 代码".to_string())?; + if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') { + return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string()); + } + Ok(()) + }); + if let Err(error) = validated { + return mcp_tool_result(error, Vec::new(), true); + } + call_client_tool_bridge("agc_unity_execute", arguments).await +} + fn mcp_success(id: Value, result: Value) -> Value { json!({ "jsonrpc": "2.0", "id": id, "result": result }) } @@ -1757,6 +1811,8 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< "agc_write_file" => call_agc_write_file(&arguments).await, #[cfg(all(windows, feature = "cocos-editor-execute"))] "agc_cocos_execute" => call_agc_cocos_execute(&arguments).await, + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + "agc_unity_execute" => call_agc_unity_execute(&arguments).await, #[cfg(all(windows, feature = "cocos-editor-execute"))] operation if cocos_editor_bridge::is_cocos_operation(operation) => { call_client_tool_bridge(operation, &arguments).await @@ -2001,6 +2057,110 @@ mod tests { } } + #[tokio::test] + async fn builtin_editor_tools_follow_independent_switches_for_non_engine_projects() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-"); + std::fs::create_dir_all(project.path().join(".agent")).unwrap(); + std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); + let bridge = + super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) + .await + .unwrap(); + for (cocos_enabled, unity_enabled) in + [(false, false), (true, false), (false, true), (true, true)] + { + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + cocos_enabled, + ) + .unwrap(); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + unity_enabled, + ) + .unwrap(); + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + bridge.url().to_string(), + call_client_tool_bridge("builtin.plugins.tools", &json!({})), + ) + .await; + assert_eq!(response["isError"], false); + let available: Value = + serde_json::from_str(response["content"][0]["text"].as_str().unwrap()).unwrap(); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + let cocos_expected = + cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute")); + for (runtime_tool, mcp_tool, expected) in [ + ( + crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME, + "agc_cocos_execute", + cocos_expected, + ), + ( + crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME, + "agc_unity_execute", + unity_enabled + && cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )), + ), + ] { + assert_eq!( + available["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool == runtime_tool), + expected, + "{runtime_tool}" + ); + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == mcp_tool), + expected, + "{mcp_tool}" + ); + } + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .filter(|tool| tool["name"] + .as_str() + .is_some_and(cocos_editor_bridge::is_cocos_operation)) + .count(), + if cocos_expected { + cocos_editor_bridge::cocos_operation_catalog().len() + } else { + 0 + }, + ); + } + std::fs::write(config.path().join("extensions/builtin-plugins.json"), "{").unwrap(); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + for tool in ["agc_cocos_execute", "agc_unity_execute"] { + assert!(!specs["tools"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["name"] == tool)); + } + } + #[cfg(all(windows, feature = "cocos-editor-execute"))] #[test] fn builtin_mcp_process_probe() { @@ -2041,13 +2201,6 @@ mod tests { let config = tempfile::tempdir().unwrap(); crate::builtin_plugins::initialize(config.path()).unwrap(); let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-"); - // 工具目录现在按当前项目类型过滤,fixture 必须具备最小 Cocos Creator 结构。 - std::fs::write( - project.path().join("package.json"), - r#"{"creator":{"version":"3.8.8"}}"#, - ) - .unwrap(); - std::fs::create_dir(project.path().join("assets")).unwrap(); std::fs::create_dir_all(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); let bridge = @@ -2523,7 +2676,7 @@ mod tests { // 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。 let unsupported_kind = call_agc_generate_image(&json!({ "prompt": "像素月光主角", - "kind": "game-art" + "kind": "future-kind" })) .await; assert_eq!(unsupported_kind["isError"], json!(true)); @@ -2533,7 +2686,7 @@ mod tests { let oversized_prompt = call_agc_generate_image(&json!({ "prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1), - "kind": "art-spritesheet" + "kind": "icon-spritesheet" })) .await; assert_eq!(oversized_prompt["isError"], json!(true)); @@ -2897,12 +3050,12 @@ mod tests { fn tool_chain_image_asset(id: &str, local_path: &str) -> GameCreationAppAssetManifestEntry { GameCreationAppAssetManifestEntry { id: id.to_string(), - kind: "image".to_string(), + kind: GameCreationAppAssetKind::Image, media_type: "image/png".to_string(), local_path: local_path.to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("image"), + category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Image), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index a4327aff2..d59c7a493 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -413,7 +413,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) output_path: Option, pub(crate) aspect_ratio: String, pub(crate) image_size: String, - pub(crate) asset_kind: String, + pub(crate) asset_kind: GameCreationAppAssetKind, pub(crate) asset_label: String, pub(crate) replace_existing: bool, pub(crate) slice_count: Option, @@ -443,7 +443,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { /// 请求正文。同任务的幂等恢复由调用方继续用同一个栏目提交(与 `reference_asset_ids` 同口径)。 pub(crate) target_category: Option, /// 抠图纯色背景(auto/省略已归一为 None;Some 时是规范化后的大写 #RRGGBB)。 - /// 仅 character 与 art-spritesheet 链路透传给服务端。 + /// 仅 character 与 icon-spritesheet 链路透传给服务端。 pub(crate) screen_color: Option, } @@ -453,7 +453,7 @@ impl Default for PlatformArtAssetGenerationOptions { output_path: None, aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "game-art".to_string(), + asset_kind: GameCreationAppAssetKind::Image, asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, slice_count: None, @@ -504,47 +504,47 @@ const PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS: usize = 128; /// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录, /// 两条调用路径不允许各写一套白名单;目录外的 kind 一律拒绝,不做兜底猜测。 /// -/// `spec` 只是平台图片生成的 generation kind 名称,客户端真正验证过的规范图类型是 -/// `icon-spec`:`build_platform_art_asset_prompt`、`platform_art_asset_art_spec` 与 -/// `canonical_art_spec_reference_at` 都只认 `icon-spec`,登记成 `spec` 的图既拿不到规范图 -/// 提示词,也无法作为 UI 原型与透明图集的权威参考。因此 `spec` 在 -/// `normalize_platform_art_asset_generation_kind` 里统一收口到 `icon-spec`。 -pub(crate) const PLATFORM_ART_ASSET_GENERATION_KINDS: &[&str] = &[ - "image", - "character", - "spec", - "icon-spec", - "ui-prototype", - "art-spritesheet", - "publication-material", +/// 生成通道只接受这些 canonical manifest kind;平台请求的 `generationKind` 是独立协议字段, +/// 由执行阶段根据 enum 成员派生。 +pub(crate) const PLATFORM_ART_ASSET_GENERATION_KINDS: &[GameCreationAppAssetKind] = &[ + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + GameCreationAppAssetKind::PublicationMaterial, ]; /// 把外部传入的 kind 收口到 `PlatformArtAssetGenerationOptions::asset_kind` 的权威取值。 /// 返回 `None` 表示该 kind 未被验证过,调用方必须拒绝。 -pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option<&'static str> { - let kind = kind.trim(); - let canonical = PLATFORM_ART_ASSET_GENERATION_KINDS +pub(crate) fn normalize_platform_art_asset_generation_kind( + kind: &str, +) -> Option { + let parsed = GameCreationAppAssetKind::parse_with_context(kind, "canvas.asset_kind"); + PLATFORM_ART_ASSET_GENERATION_KINDS .iter() - .find(|candidate| **candidate == kind)?; - Some(if *canonical == "spec" { - "icon-spec" - } else { - canonical - }) + .copied() + .find(|candidate| *candidate == parsed) } /// 需要规范图前置的生成类型:这些请求必须解析出当前账号的规范图引用,用户参考最多 /// [`PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC`] 张。 -pub(crate) fn platform_art_asset_kind_requires_canonical_spec_reference(asset_kind: &str) -> bool { +pub(crate) fn platform_art_asset_kind_requires_canonical_spec_reference( + asset_kind: GameCreationAppAssetKind, +) -> bool { matches!( asset_kind, - "ui-prototype" | "game-background" | "art-spritesheet" + GameCreationAppAssetKind::UiDesign + | GameCreationAppAssetKind::Scene + | GameCreationAppAssetKind::IconSpritesheet ) } /// 只有单规范引用的图集操作不接受用户参考:非法参考必须在原生提交处**拒绝**,不能静默丢弃。 -pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets(asset_kind: &str) -> bool { - asset_kind != "art-spritesheet" +pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets( + asset_kind: GameCreationAppAssetKind, +) -> bool { + asset_kind != GameCreationAppAssetKind::IconSpritesheet } /// 收口参考素材 id 入参:trim、去重(保持给出顺序),并拒绝路径 / 远端资源 ID / 跨项目身份。 @@ -552,7 +552,7 @@ pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets(asset_kind: /// 这里只做**形状与数量**校验;「是不是当前项目已登记图片素材」由 /// [`manifest_asset_remote_reference_at`] 用 manifest 身份与图片解码证明,不靠命名猜测。 pub(crate) fn normalize_platform_art_reference_asset_ids( - asset_kind: &str, + asset_kind: GameCreationAppAssetKind, asset_ids: &[String], ) -> Result, String> { if !platform_art_asset_kind_accepts_user_reference_assets(asset_kind) && !asset_ids.is_empty() { @@ -609,7 +609,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( "assets/art-spec.png", "1:1", "1K", - "icon-spec", + GameCreationAppAssetKind::IconSpec, "游戏统一视觉规范图", "spec", ), @@ -617,7 +617,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( "assets/ui-prototype.png", "16:9", "2K", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, "游戏横屏界面原型图", "ui-design", ), @@ -625,7 +625,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( "assets/art-spritesheet.png", "1:1", "1K", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "游戏首版核心美术素材", "icon-spritesheet", ), @@ -645,15 +645,17 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( if options.output_path.is_none() { options.output_path = Some(path.to_string()); } - for (value, default) in [ - (&mut options.aspect_ratio, ratio), - (&mut options.image_size, size), - (&mut options.asset_kind, kind), - (&mut options.asset_label, label), - ] { - if value.is_empty() { - *value = default.to_string(); - } + if options.aspect_ratio.is_empty() { + options.aspect_ratio = ratio.to_string(); + } + if options.image_size.is_empty() { + options.image_size = size.to_string(); + } + if options.asset_kind == GameCreationAppAssetKind::Unknown { + options.asset_kind = kind; + } + if options.asset_label.is_empty() { + options.asset_label = label.to_string(); } let snapshot = platform_art_generation_runtime_request_snapshot(&state)?; // 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。 @@ -1787,7 +1789,7 @@ fn canonical_art_spec_manifest_entry_at( .iter() .find(|asset| { asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH - && asset.kind == "icon-spec" + && asset.kind == GameCreationAppAssetKind::IconSpec && asset.media_type.starts_with("image/") && asset.source.kind == GameCreationAppAssetSourceKind::Canvas }) @@ -1889,7 +1891,7 @@ async fn upload_manifest_asset_remote_reference_at( &source.id, &format!("{:x}", Sha256::digest(&bytes)), &source.media_type, - &source.kind, + source.kind.as_str(), )?; if let Some(binding) = read_external_editor_resource_binding_at( root, @@ -2125,11 +2127,11 @@ async fn resolve_platform_art_generation_references_at( options: &PlatformArtAssetGenerationOptions, ) -> Result { let user_reference_asset_ids = normalize_platform_art_reference_asset_ids( - &options.asset_kind, + options.asset_kind, &options.reference_asset_ids, )?; let requires_canonical = - platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind); + platform_art_asset_kind_requires_canonical_spec_reference(options.asset_kind); // 预检:本次请求要用到的所有参考(规范图 + 用户参考)先在本地全部验证一遍, // 任何一个不合格都必须在**任何上传之前**失败,避免「第一张参考已上传、第二张坏图才失败」。 if requires_canonical || !user_reference_asset_ids.is_empty() { @@ -2507,8 +2509,8 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - if options.asset_kind != "art-spritesheet" { - return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { + return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); } let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); let runtime_context = @@ -2542,7 +2544,7 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { output_path: Option<&'a str>, aspect_ratio: &'a str, image_size: &'a str, - asset_kind: &'a str, + asset_kind: &'a GameCreationAppAssetKind, asset_label: &'a str, replace_existing: bool, require_slices: bool, @@ -2712,9 +2714,9 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ /// 这里判的是「形状」,不是具体身份:规范图到底是什么由调用方用当前账号的解析结果单独比对。 /// 提交侧的顺序合同固定为「规范图前置在最前,用户参考按给定顺序追加在后」,所以: /// -/// - `art-spritesheet`:只接受唯一规范引用,多一个用户参考都不算同合同; -/// - `ui-prototype` / `game-background`:必须有规范图前置(至少 1 项),总数不超过总上限; -/// - 其余放行 kind(`icon-spec` 等):没有规范前置,可以零参考,也可以全是用户参考; +/// - `IconSpritesheet`:只接受唯一规范引用,多一个用户参考都不算同合同; +/// - `UiDesign` / `Scene`:必须有规范图前置(至少 1 项),总数不超过总上限; +/// - 其余放行 kind(`IconSpec` 等):没有规范前置,可以零参考,也可以全是用户参考; /// - 不在目录里的 kind 一律判为不符合,失败关闭,不做兜底猜测。 /// /// 提交侧的收口在 [`normalize_platform_art_reference_asset_ids`] 与 @@ -2722,7 +2724,7 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ /// 是不是该 kind 的合法形状」,两边必须共用同一套上限,否则恢复校验会误判合法请求。 pub(crate) fn platform_art_runtime_references_match_request_contract( reference_resource_ids: &[String], - expected_asset_kind: &str, + expected_asset_kind: GameCreationAppAssetKind, ) -> bool { if reference_resource_ids .iter() @@ -2730,9 +2732,11 @@ pub(crate) fn platform_art_runtime_references_match_request_contract( { return false; } - // `game-background` 只由 Direct 运行时直接构造 options,不走 kind 归一目录,所以单独放行。 - if expected_asset_kind != "game-background" - && !PLATFORM_ART_ASSET_GENERATION_KINDS.contains(&expected_asset_kind) + // `Scene` 只由 Direct 运行时直接构造 options,不走 GUI kind 目录,所以单独放行。 + if expected_asset_kind != GameCreationAppAssetKind::Scene + && !PLATFORM_ART_ASSET_GENERATION_KINDS + .iter() + .any(|kind| *kind == expected_asset_kind) { return false; } @@ -2748,7 +2752,7 @@ pub(crate) fn platform_art_runtime_references_match_request_contract( pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_direct_stage_at( root: &Path, runtime_context: &PlatformArtGenerationRuntimeContext, - expected_asset_kind: &str, + expected_asset_kind: GameCreationAppAssetKind, ) -> Result { let Some(state) = read_platform_art_generation_runtime_state(root, runtime_context)? else { return Ok(false); @@ -2765,34 +2769,32 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d .and_then(|value| value.get("assetType")) .and_then(serde_json::Value::as_str); let matches = match expected_asset_kind { - // 参考形状按请求合同判定,不再把「icon-spec 必须没有参考」当身份判据: - // 图标规范允许普通参考,恢复校验必须与提交时同一套上限,否则会误判合法的保留账本。 - "icon-spec" => { + GameCreationAppAssetKind::IconSpec => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" && platform_art_runtime_references_match_request_contract( &snapshot.reference_resource_ids, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ) && request_asset_kind.as_deref() == Some("icon-spec") && art_spec_asset_type == Some("icon-spec") } - "game-background" => { + GameCreationAppAssetKind::Scene => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" && platform_art_runtime_references_match_request_contract( &snapshot.reference_resource_ids, - "game-background", + GameCreationAppAssetKind::Scene, ) && request_asset_kind.as_deref() == Some("game-background") && art_spec_asset_type == Some("background") } - "art-spritesheet" => { + GameCreationAppAssetKind::IconSpritesheet => { snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" && snapshot.generation_kind == "icon-spritesheet" && platform_art_runtime_references_match_request_contract( &snapshot.reference_resource_ids, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ) && request_asset_kind.is_none() && art_spec_asset_type == Some("art") @@ -2810,7 +2812,7 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_spritesheet_ide if !retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, runtime_context, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, )? { return Ok(None); } @@ -2851,11 +2853,11 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( retain_runtime_state: bool, runtime_context: &PlatformArtGenerationRuntimeContext, ) -> Result { - if require_slices && options.asset_kind != "art-spritesheet" { - return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + if require_slices && options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { + return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); } // 切分模式没有默认值:图集生成必须在客户端显式声明,缺失或自相矛盾都在付费提交前失败。 - if options.asset_kind == "art-spritesheet" { + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { let Some(slice_mode) = options.slice_mode.as_deref() else { return Err( "图集生成必须显式声明 sliceMode:等分网格或固定槽位用 grid 并提供 gridX/gridY,自由排布用 connected-components" @@ -2877,7 +2879,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( ); } } else if options.slice_mode.is_some() || options.grid_x.is_some() || options.grid_y.is_some() { - return Err("sliceMode/gridX/gridY 仅对 art-spritesheet 生效".to_string()); + return Err("sliceMode/gridX/gridY 仅对 icon-spritesheet 生效".to_string()); } if super::external_generation_state::is_standalone_platform_art_generation_runtime_context( runtime_context, @@ -3179,15 +3181,16 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } else { let canvas_context = prepare_external_canvas_generation_context(root, &client, &binding_access).await?; - let generation_kind = match options.asset_kind.as_str() { - "ui-prototype" => "ui-design", - "art-spritesheet" => "icon-spritesheet", - "image" => "image", - "character" => "character", - "publication-material" => "publication-material", + let generation_kind = match options.asset_kind { + GameCreationAppAssetKind::UiDesign => "ui-design", + GameCreationAppAssetKind::IconSpritesheet => "icon-spritesheet", + GameCreationAppAssetKind::Image => "image", + GameCreationAppAssetKind::Character => "character", + GameCreationAppAssetKind::PublicationMaterial => "publication-material", _ => "spec", }; - let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; + let is_canonical_art_spritesheet = + options.asset_kind == GameCreationAppAssetKind::IconSpritesheet; // 参考顺序「规范图在前、用户参考随后」与去重、上限都在这里统一决定, // 不区分 GUI 与 agent 调用路径;图集类型的用户参考已在解析处被拒绝。 let references = resolve_platform_art_generation_references_at( @@ -3235,7 +3238,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "kind": generation_kind, "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, - "assetKind": options.asset_kind, + "assetKind": options.asset_kind.as_str(), "assetLabel": options.asset_label, "projectId": canvas_context.project_id, "assetFolderId": canvas_context.asset_folder_id, @@ -3251,16 +3254,18 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) }; let request_body = if matches!( - options.asset_kind.as_str(), - "image" | "character" | "publication-material" + options.asset_kind, + GameCreationAppAssetKind::Image + | GameCreationAppAssetKind::Character + | GameCreationAppAssetKind::PublicationMaterial ) { let mut request_body = request_body; if let Some(object) = request_body.as_object_mut() { object.remove("generationInputs"); - if options.asset_kind == "image" { + if options.asset_kind == GameCreationAppAssetKind::Image { object.remove("kind"); } - if options.asset_kind == "character" { + if options.asset_kind == GameCreationAppAssetKind::Character { if let Some(screen_color) = options.screen_color.as_deref() { object.insert( "screenColor".to_string(), @@ -7315,7 +7320,7 @@ fn register_platform_art_slice_manifest_entries_at( .iter_mut() .find(|asset| asset.local_path == registration.local_path) { - existing.kind = "art-spritesheet-slice".to_string(); + existing.kind = GameCreationAppAssetKind::Icon; existing.media_type = registration.media_type.clone(); existing.image_sequence_frames = None; existing.image_sequence_duration_ms = None; @@ -7329,12 +7334,14 @@ fn register_platform_art_slice_manifest_entries_at( ); manifest.assets.push(GameCreationAppAssetManifestEntry { id: id.clone(), - kind: "art-spritesheet-slice".to_string(), + kind: GameCreationAppAssetKind::Icon, media_type: registration.media_type.clone(), local_path: registration.local_path.clone(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"), + category: game_creation_app_asset_category_for_kind( + GameCreationAppAssetKind::Icon, + ), tags: Vec::new(), source, }); @@ -7366,7 +7373,7 @@ fn existing_platform_art_slice_registrations_are_complete( return Ok(false); }; if candidates.next().is_some() - || asset.kind != "art-spritesheet-slice" + || asset.kind != GameCreationAppAssetKind::Icon || asset.media_type != "image/png" || asset.source.kind != GameCreationAppAssetSourceKind::Canvas || asset.source.generation_route.as_deref() @@ -7455,10 +7462,15 @@ pub(in crate::agent) fn register_existing_platform_art_slices_at( .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| "旧项目图集源资源缺少 sourceResourceId".to_string())?; - let has_partial_or_invalid_registration = manifest - .assets - .iter() - .any(|asset| asset.kind == "art-spritesheet-slice"); + // 判据只看路径:`assets/art-spritesheet-slices/` 是切片专用目录,任何落在这里的登记都是 + // 切片登记。不能再要求 `kind == Icon`——历史项目里切片刻的 kind 是非 canonical 值 + // (`art-spritesheet-slice` 等),严格解析后收口成 `unknown`,加上 kind 判据就会漏掉这些 + // 半成品登记,让下面的回填被静默跳过。 + let has_partial_or_invalid_registration = manifest.assets.iter().any(|asset| { + asset + .local_path + .starts_with("assets/art-spritesheet-slices/") + }); let receipt_path = resolve_local_project_path(root, ".agent/runtime/art-spritesheet-contract.json")?; @@ -8084,7 +8096,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( let registered = match register_local_asset_entry_with_category( root, &local_path, - &options.asset_kind, + options.asset_kind, &download.media_type, "platform-art", GameCreationAppAssetSource { @@ -8215,7 +8227,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( pub(crate) fn platform_art_asset_art_spec( options: &PlatformArtAssetGenerationOptions, ) -> serde_json::Value { - if options.asset_kind == "icon-spec" { + if options.asset_kind == GameCreationAppAssetKind::IconSpec { return serde_json::json!({ "assetType": "icon-spec", "subject": "游戏角色、场景、UI 图标与反馈特效的统一视觉规范", @@ -8227,7 +8239,7 @@ pub(crate) fn platform_art_asset_art_spec( "references": [], }); } - if options.asset_kind == "ui-prototype" { + if options.asset_kind == GameCreationAppAssetKind::UiDesign { return serde_json::json!({ "assetType": "ui", "subject": "严格依据当前项目玩法合同生成的完整游戏 UI 原型,包含状态 HUD、主要可玩区域、关键实体、操作控件、失败与重开流程", @@ -8239,7 +8251,7 @@ pub(crate) fn platform_art_asset_art_spec( "references": [], }); } - if options.asset_kind == "game-background" { + if options.asset_kind == GameCreationAppAssetKind::Scene { return serde_json::json!({ "assetType": "background", "subject": format!("{};使用项目原创命名和原创场景设计", options.asset_label), @@ -8266,37 +8278,37 @@ pub(crate) fn build_platform_art_asset_prompt( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> String { - if options.asset_kind == "image" { + if options.asset_kind == GameCreationAppAssetKind::Image { return format!( "根据用户需求生成一张全新的原创图片。主体、环境、风格、构图、光线和色彩以用户描述为准;不要生成素材图集、规范展板、完整游戏截图或文字说明。不得修改或复述为已有图片编辑。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "character" { + if options.asset_kind == GameCreationAppAssetKind::Character { return format!( "根据用户需求生成一张全新的原创角色形象或人物立绘。清楚表现角色外貌、服饰、姿势、表情、画风、构图和背景;只生成一张完整图片,不要生成图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "publication-material" { + if options.asset_kind == GameCreationAppAssetKind::PublicationMaterial { return format!( "根据用户需求生成一张全新的原创游戏发布宣传图。突出主体、卖点、氛围、构图、色彩和适合发布展示的画面层次;只生成一张完整图片,不要生成素材图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "icon-spec" { + if options.asset_kind == GameCreationAppAssetKind::IconSpec { return format!( "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "ui-prototype" { + if options.asset_kind == GameCreationAppAssetKind::UiDesign { return format!( "生成一张真正的游戏 UI/UX 原型图,不是场景概念图。必须严格从下方当前项目 UI 需求提取玩法,不得自行假设它属于塔防或加入需求中不存在的单位卡牌、费用、波次、敌人入口等结构。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画无 HUD 场景、概念图、地图、海报或纯插画。玩法机制只用于理解功能,不授权复刻现有作品;必须采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言,不得使用现有游戏 Logo、贴图、标志性布局或受保护视觉语言。\n\n当前项目 UI 需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "game-background" { + if options.asset_kind == GameCreationAppAssetKind::Scene { return format!( "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) @@ -8327,57 +8339,47 @@ mod canvas_generation_tests { }; #[test] - fn generation_kind_catalog_normalizes_spec_onto_the_verified_icon_spec() { - // 目录就是两条调用路径共同的可生成集合,必须逐字固定。 + fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() { assert_eq!( PLATFORM_ART_ASSET_GENERATION_KINDS, &[ - "image", - "character", - "spec", - "icon-spec", - "ui-prototype", - "art-spritesheet", - "publication-material" + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + GameCreationAppAssetKind::PublicationMaterial, ] ); - for kind in ["image", "character", "ui-prototype", "art-spritesheet"] { + for kind in [ + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + ] { assert_eq!( - normalize_platform_art_asset_generation_kind(kind), + normalize_platform_art_asset_generation_kind(kind.as_str()), Some(kind), - "{kind} 必须原样落到 assetKind" + "{} 必须原样落到 assetKind", + kind.as_str() ); } - // `spec` 只是服务端 generation kind;客户端验证过的规范图类型是 icon-spec, - // 提示词与规范图引用都只认它,所以这里必须收口而不是放行原值。 - assert_eq!( - normalize_platform_art_asset_generation_kind("spec"), - Some("icon-spec") - ); - assert_eq!( - normalize_platform_art_asset_generation_kind("icon-spec"), - Some("icon-spec") - ); - assert_eq!( - normalize_platform_art_asset_generation_kind(" art-spritesheet "), - Some("art-spritesheet") - ); - // 未验证的 kind 一律拒绝,不做兜底猜测。 - for kind in ["game-art", "game-background", "UI", "asset", "scene", ""] { + for kind in ["future-kind", "", "UI"] { assert_eq!( normalize_platform_art_asset_generation_kind(kind), None, - "{kind} 不在已审核目录内,必须被拒绝" + "{kind} 不是当前生成入口的 canonical kind" ); } } #[test] fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() { - // 放行 art-spritesheet 必须真的走到图集提示词与图集请求合同, + // 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同, // 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。 let options = PlatformArtAssetGenerationOptions { - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, ..PlatformArtAssetGenerationOptions::default() }; assert!( @@ -8580,7 +8582,7 @@ mod canvas_generation_tests { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/manual-concurrent.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "并发手工素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -8758,7 +8760,7 @@ mod canvas_generation_tests { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/manual-failed-retry.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "失败后可重试素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -8811,7 +8813,7 @@ mod canvas_generation_tests { output_path: Some("assets/manual-art.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "game-background".to_string(), + asset_kind: GameCreationAppAssetKind::Scene, asset_label: "手工背景".to_string(), replace_existing: true, slice_count: None, @@ -8879,7 +8881,7 @@ mod canvas_generation_tests { changed.image_size = "1K".to_string(); changed_options.push(changed); let mut changed = options.clone(); - changed.asset_kind = "ui-prototype".to_string(); + changed.asset_kind = GameCreationAppAssetKind::UiDesign; changed_options.push(changed); let mut changed = options.clone(); changed.asset_label = "另一个标签".to_string(); @@ -8934,7 +8936,7 @@ mod canvas_generation_tests { .expect("init independent slot project"); let options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -9202,13 +9204,13 @@ mod canvas_generation_tests { // 升级前它们共用同一个 (automatic-output) 槽,因此第二条必然被拒;升级后各自成槽。 let first_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "并发素材一".to_string(), ..PlatformArtAssetGenerationOptions::default() }; let second_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "并发素材二".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -9316,7 +9318,7 @@ mod canvas_generation_tests { .expect("init legacy slot project"); let options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -9436,7 +9438,7 @@ mod canvas_generation_tests { // 另一个精确动作的旧槽账本不得被本次动作消费、改写或删除。 let other_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "另一个图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -10864,7 +10866,7 @@ mod canvas_generation_tests { output_path: Some("assets/art-spec.png".to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "整包规范图".to_string(), replace_existing: false, slice_count: None, @@ -11259,7 +11261,7 @@ mod canvas_generation_tests { }; let current_prompt = "账号 A 已提交的生成请求"; let current_options = PlatformArtAssetGenerationOptions { - asset_kind: "game-art".to_string(), + asset_kind: GameCreationAppAssetKind::Image, asset_label: "账号隔离测试".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -11467,7 +11469,7 @@ mod canvas_generation_tests { let current_prompt = "恢复时使用同一个接受后生成意图"; let current_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "game-art".to_string(), + asset_kind: GameCreationAppAssetKind::Image, asset_label: "当前标签".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -11710,7 +11712,7 @@ mod canvas_generation_tests { register_local_asset_at( root, "assets/art-spec.png", - "icon-spec", + GameCreationAppAssetKind::IconSpec, "image/png", "changed-reference-test", GameCreationAppAssetSource { @@ -11771,7 +11773,7 @@ mod canvas_generation_tests { output_path: Some("assets/direct-game-background.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "1K".to_string(), - asset_kind: "game-background".to_string(), + asset_kind: GameCreationAppAssetKind::Scene, asset_label: "整包背景图".to_string(), replace_existing: false, slice_count: None, @@ -12238,7 +12240,7 @@ mod canvas_generation_tests { output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, slice_count: None, @@ -12282,7 +12284,7 @@ mod canvas_generation_tests { output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), aspect_ratio: String::new(), image_size: String::new(), - asset_kind: String::new(), + asset_kind: GameCreationAppAssetKind::Unknown, asset_label: String::new(), ..Default::default() }; @@ -12300,7 +12302,7 @@ mod canvas_generation_tests { .unwrap() .is_none()); let mut explicit_options = sparse_options.clone(); - explicit_options.asset_kind = "game-background".to_string(); + explicit_options.asset_kind = GameCreationAppAssetKind::Scene; assert!(recover_persisted_visual_generation_options( root, &pending, @@ -12611,7 +12613,7 @@ mod canvas_generation_tests { import_canvas_asset_at( root, AGENT_RUNTIME_ART_SPEC_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "image/png", "historical-canvas-project", Some("historical-resource".to_string()), @@ -12856,8 +12858,11 @@ mod canvas_generation_tests { }; // 去重保持给出顺序,空白项直接丢弃。 assert_eq!( - normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[" b ", "a", "b", " "])) - .expect("normalize icon-spec references"), + normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::IconSpec, + &ids(&[" b ", "a", "b", " "]) + ) + .expect("normalize icon-spec references"), ids(&["b", "a"]) ); // 路径、跨项目远端资源 ID 与非法字符都不是可接受的素材身份。 @@ -12869,14 +12874,18 @@ mod canvas_generation_tests { &"a".repeat(PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + 1), ] { assert!( - normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[rejected])).is_err(), + normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::IconSpec, + &ids(&[rejected]) + ) + .is_err(), "{rejected} 不能被当成参考素材 id" ); } // 无规范前置:最多 5 张;有规范前置:用户参考最多 4 张。 assert_eq!( normalize_platform_art_reference_asset_ids( - "icon-spec", + GameCreationAppAssetKind::IconSpec, &ids(&["a", "b", "c", "d", "e"]) ) .expect("five references without a canonical spec") @@ -12884,30 +12893,36 @@ mod canvas_generation_tests { 5 ); assert!(normalize_platform_art_reference_asset_ids( - "icon-spec", + GameCreationAppAssetKind::IconSpec, &ids(&["a", "b", "c", "d", "e", "f"]) ) .is_err()); assert_eq!( - normalize_platform_art_reference_asset_ids("ui-prototype", &ids(&["a", "b", "c", "d"])) - .expect("four user references with a canonical spec") - .len(), + normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::UiDesign, + &ids(&["a", "b", "c", "d"]) + ) + .expect("four user references with a canonical spec") + .len(), 4 ); assert!(normalize_platform_art_reference_asset_ids( - "ui-prototype", + GameCreationAppAssetKind::UiDesign, &ids(&["a", "b", "c", "d", "e"]) ) .is_err()); // 图集只接受单规范引用:额外参考必须被拒绝,不能静默丢弃。 - assert!( - normalize_platform_art_reference_asset_ids("art-spritesheet", &ids(&["a"])).is_err() - ); - assert!( - normalize_platform_art_reference_asset_ids("art-spritesheet", &[]) - .expect("spritesheet without user references") - .is_empty() - ); + assert!(normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::IconSpritesheet, + &ids(&["a"]) + ) + .is_err()); + assert!(normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::IconSpritesheet, + &[] + ) + .expect("spritesheet without user references") + .is_empty()); } /// 恢复侧与提交侧必须共用同一套参考上限,否则合法账本会被判成身份不符。 @@ -12921,18 +12936,21 @@ mod canvas_generation_tests { // 根素材(icon-spec):没有规范前置,可以零参考,也可以全是用户参考。 assert!(platform_art_runtime_references_match_request_contract( &[], - "icon-spec" + GameCreationAppAssetKind::IconSpec )); assert!(platform_art_runtime_references_match_request_contract( &references(5), - "icon-spec" + GameCreationAppAssetKind::IconSpec )); assert!(!platform_art_runtime_references_match_request_contract( &references(6), - "icon-spec" + GameCreationAppAssetKind::IconSpec )); // 有规范前置:规范图必须在场,总量仍不超过 5 张(含规范图)。 - for kind in ["ui-prototype", "game-background"] { + for kind in [ + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::Scene, + ] { assert!( !platform_art_runtime_references_match_request_contract(&[], kind), "{kind} 必须有规范图前置" @@ -12953,32 +12971,32 @@ mod canvas_generation_tests { // 图集:恰好一项,多一项都不算同一份请求合同。 assert!(!platform_art_runtime_references_match_request_contract( &[], - "art-spritesheet" + GameCreationAppAssetKind::IconSpritesheet )); assert!(platform_art_runtime_references_match_request_contract( &references(1), - "art-spritesheet" + GameCreationAppAssetKind::IconSpritesheet )); assert!(!platform_art_runtime_references_match_request_contract( &references(2), - "art-spritesheet" + GameCreationAppAssetKind::IconSpritesheet )); // 普通图片类生成与规范图共用总上限;空白项与未知 kind 一律拒绝。 assert!(platform_art_runtime_references_match_request_contract( &references(5), - "image" + GameCreationAppAssetKind::Image )); assert!(!platform_art_runtime_references_match_request_contract( &references(6), - "image" + GameCreationAppAssetKind::Image )); assert!(!platform_art_runtime_references_match_request_contract( &[" ".to_string()], - "icon-spec" + GameCreationAppAssetKind::IconSpec )); assert!(!platform_art_runtime_references_match_request_contract( &references(1), - "unknown-kind" + GameCreationAppAssetKind::Unknown )); } @@ -13062,7 +13080,7 @@ mod canvas_generation_tests { retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, &icon_spec, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ) .expect("read icon-spec ledger with user references") ); @@ -13086,7 +13104,7 @@ mod canvas_generation_tests { retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, &background, - "game-background", + GameCreationAppAssetKind::Scene, ) .expect("read game-background ledger with a canonical and a user reference") ); @@ -13108,7 +13126,7 @@ mod canvas_generation_tests { retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, &spritesheet, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ) .expect("read art-spritesheet ledger with the canonical reference") ); @@ -13139,7 +13157,7 @@ mod canvas_generation_tests { !retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, &over_limit, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ) .expect("read over limit icon-spec ledger") ); @@ -13168,7 +13186,7 @@ mod canvas_generation_tests { output_path: Some("assets/art-spritesheet.png".to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, slice_count: None, @@ -13611,7 +13629,7 @@ mod canvas_generation_tests { let first_slice_ids = registered .assets .iter() - .filter(|asset| asset.kind == "art-spritesheet-slice") + .filter(|asset| asset.kind == GameCreationAppAssetKind::Icon) .map(|asset| { assert_eq!(asset.source.kind, GameCreationAppAssetSourceKind::Canvas); assert_eq!( @@ -13645,7 +13663,7 @@ mod canvas_generation_tests { .clone(); mutate_manifest_at(root, |manifest| { manifest.assets.retain(|asset| { - asset.kind != "art-spritesheet-slice" || asset.local_path == retained_path + asset.kind != GameCreationAppAssetKind::Icon || asset.local_path == retained_path }); Ok(()) }) @@ -13659,7 +13677,7 @@ mod canvas_generation_tests { let backfilled_slice_ids = backfilled .assets .iter() - .filter(|asset| asset.kind == "art-spritesheet-slice") + .filter(|asset| asset.kind == GameCreationAppAssetKind::Icon) .map(|asset| (asset.local_path.clone(), asset.id.clone())) .collect::>(); assert_eq!(backfilled_slice_ids.len(), 4); @@ -13708,7 +13726,7 @@ mod canvas_generation_tests { let replaced_slice_ids = replaced .assets .iter() - .filter(|asset| asset.kind == "art-spritesheet-slice") + .filter(|asset| asset.kind == GameCreationAppAssetKind::Icon) .map(|asset| { assert_eq!( asset.source.reference_resource_ids, @@ -13741,7 +13759,7 @@ mod canvas_generation_tests { register_local_asset_entry( root, AGENT_RUNTIME_ART_SPRITESHEET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "image/png", "platform-art", GameCreationAppAssetSource { @@ -13802,12 +13820,12 @@ mod canvas_generation_tests { assert!(after .assets .iter() - .all(|asset| asset.kind != "art-spritesheet-slice")); + .all(|asset| asset.kind != GameCreationAppAssetKind::Icon)); register_local_asset_entry( root, "assets/art-spritesheet-slices/player.png", - "art-spritesheet-slice", + GameCreationAppAssetKind::Icon, "image/png", "platform-art", GameCreationAppAssetSource { @@ -15363,7 +15381,10 @@ mod canvas_generation_tests { let manifest = read_manifest_for_project(root).expect("read replacement manifest"); assert_eq!(manifest.assets.len(), 1); assert_eq!(manifest.assets[0].local_path, "assets/art-spritesheet.png"); - assert_eq!(manifest.assets[0].kind, "art-spritesheet"); + assert_eq!( + manifest.assets[0].kind, + GameCreationAppAssetKind::IconSpritesheet + ); assert_eq!( fs::read_dir(root.join("assets")) .expect("read assets directory") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 0a8974342..c88a8a19c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -31,9 +31,9 @@ fn is_direct_platform_art_generation_runtime_context( context: &PlatformArtGenerationRuntimeContext, ) -> bool { let asset_kind = match context.run_id.as_str() { - "art-spec" => "icon-spec", - "game-background" => "game-background", - "art-spritesheet" => "art-spritesheet", + "art-spec" => GameCreationAppAssetKind::IconSpec, + "game-background" => GameCreationAppAssetKind::Scene, + "art-spritesheet" => GameCreationAppAssetKind::IconSpritesheet, _ => return false, }; context.agent_id == "direct-codex-art" @@ -42,7 +42,11 @@ fn is_direct_platform_art_generation_runtime_context( && context.source == "direct-codex" && context.action_id == format!("direct-taonier-{}", context.run_id) && context.action_fingerprint - == format!("direct-taonier-art-v1:{asset_kind}:{}", context.run_id) + == format!( + "direct-taonier-art-v1:{}:{}", + asset_kind.as_str(), + context.run_id + ) } #[derive(Clone, Debug, Eq, PartialEq)] 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 dc09f659e..8e97d1242 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 @@ -98,7 +98,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result format!("commandId={}", text(&["commandId", "command_id", "id"])), - "cocos.editor.execute" => format!( + "cocos.editor.execute" | "unity.editor.execute" => format!( "codeChars={} · codeSha256={:x}", chars(&["code"]), Sha256::digest( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 00ac94cc7..ae21f2ea0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -383,6 +383,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ true, || observe_agent_runtime_cocos_editor_execute(root, action, pending_action), ), + "unity.editor.execute" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + true, + || observe_agent_runtime_unity_editor_execute(root, action, pending_action), + ), "preview.validate" => { observe_agent_runtime_preview_validate( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 071298d77..a2f24c3d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -2061,17 +2061,23 @@ mod tests { .expect("resume first manifest task fixture"); for (local_path, kind) in [ - ("assets/art-spec.png", "icon-spec"), - ("assets/ui-prototype.png", "ui-prototype"), - ("assets/art-spritesheet.png", "art-spritesheet"), + ("assets/art-spec.png", GameCreationAppAssetKind::IconSpec), + ( + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ), + ( + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ), ] { let (generation_route, generation_kind, reference_resource_ids) = match kind { - "icon-spec" => ( + GameCreationAppAssetKind::IconSpec => ( "/api/external/v1/editor/images/generations", "spec", Vec::new(), ), - "ui-prototype" => ( + GameCreationAppAssetKind::UiDesign => ( "/api/external/v1/editor/images/generations", "ui-design", vec!["resource-icon-spec".to_string()], @@ -2082,7 +2088,7 @@ mod tests { vec!["resource-icon-spec".to_string()], ), }; - let bytes = if kind == "art-spritesheet" { + let bytes = if kind == GameCreationAppAssetKind::IconSpritesheet { use image::ImageEncoder; let mut bytes = Vec::new(); image::codecs::png::PngEncoder::new(&mut bytes) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index e1fe9f4a9..40924de58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -97,6 +97,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( "command.run_limited" => Some("command.run_limited"), #[cfg(feature = "cocos-editor-execute")] "cocos.editor.execute" => Some("cocos.editor.execute"), + "unity.editor.execute" => Some("unity.editor.execute"), "preview.start" => Some("preview.start"), "preview.validate" => Some("preview.validate"), "image.inspect" => Some("image.inspect"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index fa5bbdc7f..579e6703b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1136,7 +1136,7 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked( && record .get("inspectionKind") .and_then(serde_json::Value::as_str) - == Some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND) + == Some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND) && record .get("validationProfile") .and_then(serde_json::Value::as_str) @@ -1216,11 +1216,19 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( return None; } let (expected_path, expected_kind, label) = match agent_id { - "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), - "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), + "art-director" => ( + AGENT_RUNTIME_ART_SPEC_PATH, + GameCreationAppAssetKind::IconSpec, + "统一视觉规范图", + ), + "design-foundation" => ( + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + "策划界面原型图", + ), "art-asset-plan" => ( "assets/art-spritesheet.png", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "首版美术素材图", ), _ => return None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index b38ed1023..af618da53 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -247,7 +247,10 @@ fn build_game_creator_agent_background_tool_plan_request_at( let relaxed_prompt = format!( "你正在执行一个自主游戏构建任务。请按自己的判断规划并直接调用当前广告的原生工具完成目标;任务可以与其它 Agent 并行,依赖只作为参考,不要等待或索要平台资产/验收回执。已有观察只代表已发生的事实,完成后直接调用 respond_to_user。\n\n运行上下文:\n{context}\n\n任务:\n{effective_task}\n\n已有观察:\n{observations_json}" ); - let mut function_tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?; + let mut function_tools = + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent( + agent_id, + )?; remove_relaxed_autonomous_platform_validation_tools(&mut function_tools)?; // Platform-backed generation remains an optional capability. A // relaxed run may proceed with all ordinary project tools when no @@ -336,7 +339,11 @@ fn build_game_creator_agent_background_tool_plan_request_at( render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?; let loop_index = loop_index.saturating_add(1); let context_preload_notice = game_creator_agent_context_preload_notice(agent_id); - let canvas_asset_kind_catalog = AGENT_RUNTIME_CANVAS_ASSET_KINDS.join("|"); + let canvas_asset_kind_catalog = AGENT_RUNTIME_CANVAS_ASSET_KINDS + .iter() + .map(|kind| kind.as_str()) + .collect::>() + .join("|"); let limited_command_contract = if runtime_owner_artifact_validation_available { "当前固定 owner 不得调用 command.run_limited 或 project.verify;完成固定正式产物后直接交付,由 Runtime 在收束门内执行结构验证。".to_string() } else { @@ -479,9 +486,9 @@ fn build_game_creator_agent_background_tool_plan_request_at( .with_api_kind(api_kind) .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) - .with_function_tools(build_agent_runtime_native_function_tools_for_agent( - agent_id, - )?) + .with_function_tools( + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?, + ) .with_tool_choice(platform_llm::LlmToolChoice::Required); if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools(&mut request.function_tools)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 09da3466e..a2eaea3f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -969,7 +969,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at || force_autonomous_pre_mutation { request.function_tools = - build_agent_runtime_native_function_tools_for_agent(agent_id)?; + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?; if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools( &mut request.function_tools, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 68e315d3d..86829193b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -1,7 +1,7 @@ use super::*; -pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = - &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]; +pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[GameCreationAppAssetKind] = + GameCreationAppAssetKind::CANVAS_ASSET_KINDS; #[cfg(test)] mod canvas_asset_kind_contract_tests { @@ -11,7 +11,12 @@ mod canvas_asset_kind_contract_tests { fn canvas_asset_kind_catalog_preserves_authoritative_contract() { assert_eq!( AGENT_RUNTIME_CANVAS_ASSET_KINDS, - &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"] + &[ + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + ] ); } } @@ -70,6 +75,9 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { if crate::builtin_plugins::cocos_editor_agent_tool_available() { tools.push(crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME); } + if crate::builtin_plugins::unity_editor_agent_tool_available() { + tools.push(crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME); + } tools } @@ -205,6 +213,67 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( }) } +#[cfg(test)] +mod builtin_editor_policy_tests { + use super::*; + + #[test] + fn builtin_editor_tools_follow_switches_for_non_engine_projects() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let project = crate::tests::canonical_test_tempdir("builtin-editor-policy-"); + for (cocos_enabled, unity_enabled) in + [(false, false), (true, false), (false, true), (true, true)] + { + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + cocos_enabled, + ) + .unwrap(); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + unity_enabled, + ) + .unwrap(); + let snapshot = + agent_runtime_tool_policy_snapshot_at(project.path(), "project-supervisor") + .unwrap(); + for (tool, expected) in [ + ( + crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME, + cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute")), + ), + ( + crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME, + unity_enabled + && cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )), + ), + ] { + assert_eq!( + snapshot.allowed_tools.iter().any(|entry| entry == tool), + expected, + "{tool}" + ); + assert_eq!( + snapshot + .auto_tools + .iter() + .chain(&snapshot.confirm_tools) + .chain(&snapshot.denied_tools) + .any(|entry| entry == tool), + expected, + "{tool}", + ); + } + } + } +} + pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs index bc3236a26..a24f84c3e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs @@ -16,7 +16,7 @@ pub(in crate::agent) fn art_manifest_content() -> String { "status": "generated", "assets": [{ "path": ART_SPRITESHEET_PATH, - "kind": "art-spritesheet", + "kind": GameCreationAppAssetKind::IconSpritesheet.as_str(), "usage": REQUIRED_SLICE_USAGES, }], "sliceManifest": ART_SLICE_MANIFEST_PATH, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index b19cea107..13f70e747 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -8,12 +8,12 @@ pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(roo ( "design-foundation", "assets/ui-prototype.png", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, ), ( "art-asset-plan", "assets/art-spritesheet.png", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ), ] .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index ed93b5560..370c90dda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -644,9 +644,9 @@ fn standalone_platform_art_generation_ledger_identity_is_valid( }; let direct_identity_is_valid = if agent_id == "direct-codex-art" { let expected_asset_kind = match run_id { - "art-spec" => Some("icon-spec"), - "game-background" => Some("game-background"), - "art-spritesheet" => Some("art-spritesheet"), + "art-spec" => Some(GameCreationAppAssetKind::IconSpec), + "game-background" => Some(GameCreationAppAssetKind::Scene), + "art-spritesheet" => Some(GameCreationAppAssetKind::IconSpritesheet), _ => None, }; expected_asset_kind.is_some_and(|asset_kind| { @@ -655,7 +655,9 @@ fn standalone_platform_art_generation_ledger_identity_is_valid( && string_field("source") == Some("direct-codex") && string_field("actionId") == Some(format!("direct-taonier-{run_id}").as_str()) && string_field("actionFingerprint") - == Some(format!("direct-taonier-art-v1:{asset_kind}:{run_id}").as_str()) + == Some( + format!("direct-taonier-art-v1:{}:{run_id}", asset_kind.as_str()).as_str(), + ) }) } else { false @@ -1561,10 +1563,14 @@ mod orphaned_external_generation_recovery_tests { fs::create_dir_all(workflow_path.parent().expect("workflow parent")) .expect("workflow directory"); let rollback_assets = [ - ("assets/art-spec.png", "icon-spec", b"old-spec".as_slice()), + ( + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + b"old-spec".as_slice(), + ), ( "assets/direct-game-background.png", - "game-background", + GameCreationAppAssetKind::Scene, b"old-background".as_slice(), ), ] @@ -1702,7 +1708,7 @@ mod orphaned_external_generation_recovery_tests { write_complete_stage(serde_json::json!({ "prompt": "Direct art spec", "kind": "spec", - "assetKind": "game-background", + "assetKind": "scene", "projectId": "canvas-project", "assetFolderId": "canvas-assets", "generationInputs": { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 41235d21e..4850d6fcf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -11,6 +11,7 @@ mod goal_contract; mod helpers; mod isolated_joins; mod media; +mod unity_editor; pub(in crate::agent) use media::design_foundation_ui_page_output_path_is_valid; mod policy; mod preview; @@ -38,6 +39,7 @@ pub(in crate::agent) use project_ops::*; pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; pub(in crate::agent) use ui_workflow::*; +pub(in crate::agent) use unity_editor::*; #[cfg(test)] pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; @@ -71,9 +73,8 @@ pub(crate) use isolated_joins::{ pub(crate) use media::observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test; #[allow(unused_imports)] pub(crate) use media::{ - AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, - AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, }; pub(crate) use policy::{ fail_closed_agent_runtime_confirmation_for_run, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs index ffefb5398..9d39ae45d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs @@ -33,11 +33,11 @@ pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute( detail: None, }; } - if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(root) { + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return AgentRuntimeToolObservation { tool: "cocos.editor.execute".to_string(), status: "failed".to_string(), - summary: "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(), + summary: "当前 Cocos 插件不可用".to_string(), detail: None, }; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index a61e1df4d..c3890466a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -8,14 +8,14 @@ pub(in crate::agent) struct AgentRuntimeImageInspectInput { pub(in crate::agent) question: Option, } -pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND: &str = "ui-prototype"; +pub(crate) const AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND: &str = "ui-design"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_PATH: &str = "assets/ui-prototype.png"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE: &str = "ui-prototype.v1"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE: &str = "ui-prototype.v2"; pub(crate) const AGENT_RUNTIME_ART_SPEC_PATH: &str = "assets/art-spec.png"; pub(crate) const AGENT_RUNTIME_ART_SPRITESHEET_PATH: &str = "assets/art-spritesheet.png"; -fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: &str) -> bool { +fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: GameCreationAppAssetKind) -> bool { AGENT_RUNTIME_CANVAS_ASSET_KINDS.contains(&asset_kind) } @@ -470,7 +470,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect( "images": image_metadata, "responseId": response_id, "conclusionChars": conclusion_chars, - "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND), + "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND), "validationProfile": validation_profile, "passed": passed, "checks": checks, @@ -489,7 +489,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect( "responseId": response_id, "conclusionChars": conclusion_chars, "conclusion": conclusion, - "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND), + "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND), "validationProfile": validation_profile, "passed": passed, "checks": checks, @@ -543,7 +543,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio let output_path = agent_runtime_tool_input_text(input, &["outputPath", "output_path"]); let aspect_ratio = agent_runtime_tool_input_text(input, &["aspectRatio", "aspect_ratio"]); let image_size = agent_runtime_tool_input_text(input, &["imageSize", "image_size"]); - let asset_kind = agent_runtime_tool_input_text(input, &["assetKind", "asset_kind"]); + let requested_asset_kind = agent_runtime_tool_input_text(input, &["assetKind", "asset_kind"]); + let asset_kind = GameCreationAppAssetKind::parse_with_context( + &requested_asset_kind, + "canvas.asset_generate", + ); let asset_label = agent_runtime_tool_input_text(input, &["assetLabel", "asset_label"]); let replace_existing = input .get("replaceExisting") @@ -614,7 +618,12 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio } else { requested_options.image_size }, - asset_kind: if requested_options.asset_kind.is_empty() { + // kind 已是枚举,「空串」不再可表达:只有工具入参**没有**声明 assetKind(空白)才落 + // 本通道默认值。显式给了认不出的值仍是 `Unknown`,继续在下游按「不受支持」失败关闭, + // 不做兜底猜测。 + asset_kind: if requested_options.asset_kind == GameCreationAppAssetKind::Unknown + && requested_asset_kind.trim().is_empty() + { defaults.asset_kind } else { requested_options.asset_kind @@ -703,12 +712,12 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }; } // 切分模式没有默认值:图集必须显式声明,且声明必须与 assetKind 和网格参数自洽。 - if options.asset_kind == "art-spritesheet" { + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { if options.slice_mode.is_none() { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), - summary: "assetKind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components" + summary: "assetKind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components" .to_string(), detail: None, }; @@ -743,13 +752,13 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), summary: format!( - "sliceMode/gridX/gridY/sliceCount 仅对 assetKind=art-spritesheet 生效,当前 assetKind={}", + "sliceMode/gridX/gridY/sliceCount 仅对 assetKind=icon-spritesheet 生效,当前 assetKind={}", options.asset_kind ), detail: None, }; } - if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) { + if !agent_runtime_canvas_asset_kind_is_supported(options.asset_kind) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), @@ -943,7 +952,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio ); } }; - let committed = if options.asset_kind == "art-spritesheet" { + let committed = if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { let output_path = options .output_path @@ -1227,9 +1236,11 @@ mod platform_art_generation_observation_tests { #[test] fn canvas_asset_kind_validation_accepts_shared_catalog() { for asset_kind in AGENT_RUNTIME_CANVAS_ASSET_KINDS { - assert!(agent_runtime_canvas_asset_kind_is_supported(asset_kind)); + assert!(agent_runtime_canvas_asset_kind_is_supported(*asset_kind)); } - assert!(!agent_runtime_canvas_asset_kind_is_supported("unsupported")); + assert!(!agent_runtime_canvas_asset_kind_is_supported( + GameCreationAppAssetKind::Unknown + )); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs new file mode 100644 index 000000000..d82575582 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs @@ -0,0 +1,79 @@ +use super::*; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct UnityEditorExecuteInput { + code: String, +} + +pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute( + root: &Path, + action: &AgentRuntimeToolAction, + pending_action: Option<&AgentRuntimePendingToolAction>, +) -> AgentRuntimeToolObservation { + let execution = (|| { + let input: UnityEditorExecuteInput = serde_json::from_value(action.input.clone()) + .map_err(|error| format!("unity.editor.execute 输入无效:{error}"))?; + if pending_action.is_none() { + return Err("unity.editor.execute 必须绑定 durable pending action".to_string()); + } + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("当前 Unity 插件不可用".to_string()); + } + crate::editor_adapters::execute_unity_editor_code(root, &input.code) + })(); + match execution { + Ok(response) => { + let status = match response["status"].as_str() { + Some("completed") if response["ok"] == true => "ok", + Some("needs-reconciliation") => { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } + _ => "failed", + }; + AgentRuntimeToolObservation { + tool: "unity.editor.execute".to_string(), + status: status.to_string(), + summary: match status { + "ok" => "Unity 编辑器已返回执行成功回执", + "needs-reconciliation" => "Unity 执行结果待人工核对,禁止自动重发", + _ => "Unity 编辑器执行失败", + } + .to_string(), + detail: Some(redact_agent_runtime_project_paths( + root, + &response.to_string(), + 32_000, + )), + } + } + Err(error) => AgentRuntimeToolObservation { + tool: "unity.editor.execute".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 480), + detail: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unity_execute_requires_pending_action_and_rejects_project_override() { + for input in [ + serde_json::json!({"code":"return 2;"}), + serde_json::json!({"code":"return 2;", "projectPath":"C:/other"}), + ] { + let action = AgentRuntimeToolAction { + tool: "unity.editor.execute".to_string(), + reason: None, + input, + }; + let observation = + observe_agent_runtime_unity_editor_execute(Path::new("C:/unity"), &action, None); + assert_eq!(observation.status, "failed"); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index c9ebb2019..aa35b80a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -253,18 +253,13 @@ fn build_agent_runtime_native_capability_registry( fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry, String> { - // 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即 - // 生效,又不需要每次调用都重建 registry。 - static ENABLED_REGISTRY: OnceLock, String>> = OnceLock::new(); - static DISABLED_REGISTRY: OnceLock, String>> = - OnceLock::new(); - // 缓存选择与构建消费同一份快照,避免开关变化污染另一份永久缓存。 + // 两个独立开关产生四份目录,使用同一快照选缓存并构建。 + static REGISTRIES: [OnceLock, String>>; 4] = + [const { OnceLock::new() }; 4]; let tools = agent_runtime_native_executable_tools(); - let cache = if tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME) { - &ENABLED_REGISTRY - } else { - &DISABLED_REGISTRY - }; + let index = usize::from(tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME)) + | (usize::from(tools.contains(&crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME)) << 1); + let cache = ®ISTRIES[index]; cache .get_or_init(|| build_agent_runtime_native_capability_registry(tools)) .as_ref() @@ -1035,14 +1030,15 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=art-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。" } "ui.workflow.run" => { - "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" + "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" } "cocos.editor.execute" => { "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。" } + "unity.editor.execute" => "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。", "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", "agent.delegate" => { @@ -1240,7 +1236,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } }), "command.exec" | "command.start" => command_start_input_schema(), - "cocos.editor.execute" => json!({ + "cocos.editor.execute" | "unity.editor.execute" => json!({ "type": "object", "required": ["code"], "additionalProperties": false, @@ -1305,7 +1301,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "canvas.asset_generate" => { let mut asset_kinds = AGENT_RUNTIME_CANVAS_ASSET_KINDS .iter() - .map(|kind| Value::String((*kind).to_string())) + .map(|kind| Value::String(kind.as_str().to_string())) .collect::>(); asset_kinds.push(Value::Null); json!({ @@ -1320,7 +1316,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "assetKind": { "type": ["string", "null"], "enum": asset_kinds }, "assetLabel": { "type": ["string", "null"], "maxLength": 80 }, "replaceExisting": { "type": "boolean" }, - "sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=art-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" }, + "sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=icon-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" }, "gridX": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, "gridY": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, "sliceCount": { "type": ["integer", "null"], "minimum": 1, "maximum": 256, "description": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数" } @@ -1870,7 +1866,7 @@ mod tests { let schema = runtime_tool_input_schema("canvas.asset_generate"); let mut expected = AGENT_RUNTIME_CANVAS_ASSET_KINDS .iter() - .map(|kind| Value::String((*kind).to_string())) + .map(|kind| Value::String(kind.as_str().to_string())) .collect::>(); expected.push(Value::Null); diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index f228420f6..f7b1e897d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -30,6 +30,7 @@ use crate::agent::{ }; use crate::commands::prepare_local_project_asset_generation; use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project}; +use shared_contracts::game_creation_app::GameCreationAppAssetKind; pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = @@ -69,7 +70,7 @@ const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = pub(crate) struct AssetGenerationTaskRecord { pub(crate) task_id: String, pub(crate) project_id: String, - pub(crate) kind: String, + pub(crate) kind: GameCreationAppAssetKind, pub(crate) asset_name: String, pub(crate) status: String, /// 阶段文案:**由后端拥有**,前端只渲染。 @@ -297,14 +298,14 @@ pub(crate) fn begin_local_project_asset_generation_task( root: &Path, project_id: &str, task_id: &str, - task_kind: &str, + task_kind: GameCreationAppAssetKind, asset_name: &str, output_path: Option<&str>, ) -> Result { let record = AssetGenerationTaskRecord { task_id: task_id.to_string(), project_id: project_id.trim().to_string(), - kind: task_kind.to_string(), + kind: task_kind, asset_name: asset_name.to_string(), status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), @@ -417,7 +418,7 @@ pub(crate) async fn start_local_project_asset_generation( &request.root, &project_id, &task_id, - &asset_kind, + asset_kind, &asset_label, request.options.output_path.as_deref(), )?; @@ -459,7 +460,7 @@ mod asset_generation_task_tests { init_local_game_project_at, read_manifest_for_project, write_project_permission_policy_at, }; use shared_contracts::game_creation_app::{ - GameCreationAppAssetSource, GameCreationAppAssetSourceKind, + GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, }; fn temp_project_root(label: &str) -> PathBuf { @@ -492,7 +493,7 @@ mod asset_generation_task_tests { register_local_asset_at( root, local_path, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "image/png", "generated", GameCreationAppAssetSource { @@ -524,7 +525,7 @@ mod asset_generation_task_tests { root, "project-1", task_id, - "image", + GameCreationAppAssetKind::Image, "AI 图", None, ) @@ -540,7 +541,7 @@ mod asset_generation_task_tests { root, "project-1", task_id, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "图标规范", Some(output_path), ) @@ -715,7 +716,7 @@ mod asset_generation_task_tests { &root, "project-1", "task-dup", - "image", + GameCreationAppAssetKind::Image, "AI 图", None, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 2a053191c..0710c3351 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -469,17 +469,9 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool { } /// 上传素材落盘的 manifest `kind`:只由**内容证据**推导(`mediaType` 优先,扩展名兜底)。 -/// -/// 不用 `uploaded` 这类**来源词**当类型:它不在 canonical 目录里,会经别名表落到 -/// `image → unclassified`,把本该归「音频」「文档」的上传素材一起说成图片。 -/// -/// 更不能把 `ui` 当"图片的默认类型":`ui → ui-design → ui-interaction` 会把任意上传图片 -/// 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系;这类错值还不可恢复 -/// ——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind` 能派生出非 `unclassified` -/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。 -/// -/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。 -fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str { +/// 判不出内容类型时返回 `Unknown`;调用者负责决定是否拒绝写入或保留待后续归类, +/// 这里不猜具体类型。 +fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAssetKind { let media_type = media_type.trim().to_ascii_lowercase(); let extension = Path::new(file_name) .extension() @@ -493,20 +485,20 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str { "mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus" ) { - "audio" + GameCreationAppAssetKind::Audio } else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") { - "video" + GameCreationAppAssetKind::Video } else if media_type.starts_with("font/") || matches!(extension, "ttf" | "otf" | "woff" | "woff2") { - "document" + GameCreationAppAssetKind::Font } else if media_type.starts_with("image/") || matches!( extension, "png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp" ) { - "image" + GameCreationAppAssetKind::Image } else if matches!(media_type.as_str(), "text/html" | "text/css") || media_type.contains("javascript") || media_type.contains("typescript") @@ -515,7 +507,7 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str { "html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" ) { - "code" + GameCreationAppAssetKind::Code } else if media_type.starts_with("text/") || matches!(media_type.as_str(), "application/json" | "application/xml") || matches!( @@ -533,12 +525,29 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str { | "xml" ) { - "document" + GameCreationAppAssetKind::Document } else { - "asset" + GameCreationAppAssetKind::Unknown } } +/// 登记边界的 kind 解析:空白入参按「没有信息」处理,沿用登记入口的 `Image` 默认值; +/// 非空但认不出的原值严格收口成 `Unknown` 并把原始串交给 reporter 留痕, +/// 由调用者决定是否以错误结束或继续后续归类。 +/// +/// 三条外部登记边界必须同口径:平台导入(`commands::imported_platform_asset_kind`)、 +/// 画板导入(`sync_canvas_project_assets_at`)、外部 `register_local_asset`。 +/// 只做严格等值匹配,认不出时收口为 `Unknown`。 +pub(crate) fn registration_asset_kind( + value: &str, + context: &'static str, +) -> GameCreationAppAssetKind { + if value.trim().is_empty() { + return GameCreationAppAssetKind::Image; + } + GameCreationAppAssetKind::parse_with_context(value, context) +} + pub(crate) fn upload_local_asset_at( root: &Path, file_name: &str, @@ -585,7 +594,7 @@ pub(crate) fn upload_local_asset_at( pub(crate) fn register_local_asset_at( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, source_kind: &str, source: GameCreationAppAssetSource, @@ -648,7 +657,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result let (_, asset_changed) = register_local_asset_entry_with_change( root, &relative, - "document", + GameCreationAppAssetKind::Document, media_type, "document", GameCreationAppAssetSource { @@ -673,7 +682,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result pub(crate) fn import_canvas_asset_at( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, canvas_project_id: &str, resource_id: Option, @@ -688,7 +697,6 @@ pub(crate) fn import_canvas_asset_at( if resource_id.is_none() && asset_object_id.is_none() { return Err("画板资源 ID 和资产对象 ID 至少需要一个".to_string()); } - register_local_asset_at( root, local_path, @@ -920,7 +928,12 @@ pub(crate) async fn sync_canvas_project_assets_at( &local_path, json_string_field(resource, "assetKind") .as_deref() - .unwrap_or("canvas-resource"), + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + GameCreationAppAssetKind::parse_with_context(value, "canvas.asset_sync") + }) + .unwrap_or(GameCreationAppAssetKind::Image), &download.media_type, "canvas", GameCreationAppAssetSource { @@ -1774,33 +1787,26 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result { /// 画板导出图层推断出的资源 kind。 /// -/// 返回值必须是 **canonical kind**:这个值会被 `register_local_asset_entry` 原样写入 -/// manifest,并据以派生落盘 `category`。历史上这里写过非 canonical 的 -/// `ui` / `animation` / `asset`,只能靠别名表兜底,等于同时维护两套词汇;别名表只用于 -/// 兼容存量数据,不作为新写入值的来源。 +/// 直接返回正式枚举成员;该值会被登记边界原样写入 manifest。 pub(crate) fn infer_canvas_export_asset_kind( layer: &CanvasExportLayerMetadata, file: &str, -) -> &'static str { +) -> GameCreationAppAssetKind { let layer_type = layer.visible.layer_type.as_str(); - let inferred = if file.starts_with("sequences/") - || contains_any(layer_type, &["序列", "动画", "动作"]) - { - "character-animation" + if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) { + GameCreationAppAssetKind::CharacterAnimation } else if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) { - "audio" + GameCreationAppAssetKind::Audio } else if contains_any(layer_type, &["角色"]) { - "character" + GameCreationAppAssetKind::Character } else if contains_any(layer_type, &["场景", "背景"]) { - "scene" + GameCreationAppAssetKind::Scene } else if contains_any(layer_type, &["UI", "界面", "图标"]) { - "ui-design" + GameCreationAppAssetKind::UiDesign } else { - "image" - }; - // 防御性归一:分支字面量写错时由覆盖测试暴露,这里再兜一层。 - shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(inferred) + GameCreationAppAssetKind::Image + } } pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str { @@ -1873,7 +1879,7 @@ pub(crate) fn build_canvas_project_url( pub(crate) fn register_local_asset_entry( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, id_prefix: &str, source: GameCreationAppAssetSource, @@ -1895,7 +1901,7 @@ pub(crate) fn register_local_asset_entry( pub(crate) fn register_local_asset_entry_with_category( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, id_prefix: &str, source: GameCreationAppAssetSource, @@ -1933,7 +1939,7 @@ fn normalize_asset_category_override( fn register_local_asset_entry_with_change( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, id_prefix: &str, source: GameCreationAppAssetSource, @@ -1942,7 +1948,6 @@ fn register_local_asset_entry_with_change( let normalized_path = normalize_relative_path(local_path)?; let absolute_path = resolve_local_project_path(root, &normalized_path)?; let manifest_path = root.join(".agent/manifest.json"); - let kind = if kind.is_empty() { "asset" } else { kind }; let media_type = if media_type.is_empty() { "application/octet-stream" } else { @@ -1966,7 +1971,7 @@ fn register_local_asset_entry_with_change( || existing.source != source || target_category.is_some_and(|category| existing.category != category); if existing.kind != kind { - existing.kind = kind.to_string(); + existing.kind = kind; existing.category = game_creation_app_asset_category_for_kind(kind); } // 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目, @@ -1985,7 +1990,7 @@ fn register_local_asset_entry_with_change( ); manifest.assets.push(GameCreationAppAssetManifestEntry { id: id.clone(), - kind: kind.to_string(), + kind, media_type: media_type.to_string(), local_path: normalized_path.clone(), image_sequence_frames: None, @@ -2004,7 +2009,7 @@ fn register_local_asset_entry_with_change( "recordType": record_type, "assetId": id.clone(), "localPath": normalized_path.clone(), - "kind": kind, + "kind": kind.as_str(), "mediaType": media_type, "source": source_for_record, }), @@ -2275,7 +2280,7 @@ mod tests { let registered = register_local_asset_entry_with_category( root, "assets/hero.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2291,7 +2296,7 @@ mod tests { register_local_asset_entry_with_category( root, "assets/hero.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2307,7 +2312,7 @@ mod tests { assert!(register_local_asset_entry_with_category( root, "assets/hero.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2324,7 +2329,7 @@ mod tests { let plain = register_local_asset_entry( root, "assets/plain.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2338,7 +2343,7 @@ mod tests { register_local_asset_entry( root, "assets/hero.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2351,10 +2356,6 @@ mod tests { } /// 画板导出推断出的 kind 必须已经是 canonical 值。 - /// - /// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值 - /// (历史上曾写 `ui` / `animation` / `asset`),就只能靠别名表兜底,等于同时维护 - /// 两套词汇,且已落盘的 category 无法随别名修复自愈。这里逐分支钉死。 #[test] fn canvas_export_asset_kind_is_always_canonical() { fn layer(layer_type: &str) -> CanvasExportLayerMetadata { @@ -2373,13 +2374,21 @@ mod tests { } // 每个分支的代表输入 → 期望的 canonical kind。 - let cases: [(&str, &str, &str); 6] = [ - ("序列", "sequences/01.png", "character-animation"), - ("动画", "layer.png", "character-animation"), - ("音频", "layer.png", "audio"), - ("角色", "layer.png", "character"), - ("场景", "layer.png", "scene"), - ("UI", "layer.png", "ui-design"), + let cases: [(&str, &str, GameCreationAppAssetKind); 6] = [ + ( + "序列", + "sequences/01.png", + GameCreationAppAssetKind::CharacterAnimation, + ), + ( + "动画", + "layer.png", + GameCreationAppAssetKind::CharacterAnimation, + ), + ("音频", "layer.png", GameCreationAppAssetKind::Audio), + ("角色", "layer.png", GameCreationAppAssetKind::Character), + ("场景", "layer.png", GameCreationAppAssetKind::Scene), + ("UI", "layer.png", GameCreationAppAssetKind::UiDesign), // 无匹配时落到 image。 ]; for (layer_type, file, expected) in cases { @@ -2389,65 +2398,54 @@ mod tests { // media/ 前缀同样走音频分支。 assert_eq!( infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"), - "audio" + GameCreationAppAssetKind::Audio ); - // 兜底分支是 image 而不是旧的 "asset"。 + // 无匹配时使用中性的 image。 assert_eq!( infer_canvas_export_asset_kind(&layer("图层"), "layer.png"), - "image" + GameCreationAppAssetKind::Image ); - // 所有分支的返回值都必须在 canonical 目录里,且不能是旧的非 canonical 写法。 + // 所有分支的返回值都必须是正式枚举成员。 for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] { let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png"); assert!( - shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS - .contains(&kind), + GameCreationAppAssetKind::ALL.contains(&kind), "非 canonical kind: {kind}(layer_type={layer_type})" ); - for legacy in ["ui", "animation", "asset"] { - assert_ne!(kind, legacy, "写回了非 canonical 的 {legacy}"); - } } } - /// 上传素材的 `kind` 只由内容证据推导:既不能写 `uploaded`(来源词当类型), - /// 更不能把 `ui` 当图片默认值(`ui → ui-design → ui-interaction` 会把任意上传图片 - /// 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来)。 - /// - /// 变异验证:把 `uploaded_asset_kind` 改回返回常量(`"uploaded"` 或 `"ui"`)必须让本用例变红。 #[test] - fn uploaded_asset_kind_uses_content_evidence_and_never_ui() { - assert_eq!(uploaded_asset_kind("hero.png", "image/png"), "image"); - assert_eq!(uploaded_asset_kind("../角色.png", "image/png"), "image"); - assert_eq!(uploaded_asset_kind("bg.webp", ""), "image"); - assert_eq!(uploaded_asset_kind("theme.mp3", "audio/mpeg"), "audio"); - assert_eq!(uploaded_asset_kind("intro.mp4", ""), "video"); - assert_eq!(uploaded_asset_kind("rules.md", "text/markdown"), "document"); - assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "document"); - assert_eq!(uploaded_asset_kind("game.js", "text/javascript"), "code"); - // 判不出内容类型 → 中性 `asset`(派生 `unclassified` → 「待归类」),不猜具体类型。 + fn uploaded_asset_kind_uses_content_evidence() { + assert_eq!( + uploaded_asset_kind("hero.png", "image/png"), + GameCreationAppAssetKind::Image + ); + assert_eq!( + uploaded_asset_kind("theme.mp3", "audio/mpeg"), + GameCreationAppAssetKind::Audio + ); + assert_eq!( + uploaded_asset_kind("intro.mp4", ""), + GameCreationAppAssetKind::Video + ); + assert_eq!( + uploaded_asset_kind("rules.md", "text/markdown"), + GameCreationAppAssetKind::Document + ); + assert_eq!( + uploaded_asset_kind("ui-font.ttf", "font/ttf"), + GameCreationAppAssetKind::Font + ); + assert_eq!( + uploaded_asset_kind("game.js", "text/javascript"), + GameCreationAppAssetKind::Code + ); assert_eq!( uploaded_asset_kind("unknown.bin", "application/octet-stream"), - "asset" + GameCreationAppAssetKind::Unknown ); - assert_eq!(uploaded_asset_kind("no-extension", ""), "asset"); - // 反查:正文里的"来源词"和"UI 默认值"都不得出现在返回值里。 - for (file_name, media_type) in [ - ("hero.png", "image/png"), - ("theme.mp3", "audio/mpeg"), - ("unknown.bin", ""), - ] { - let kind = uploaded_asset_kind(file_name, media_type); - assert_ne!(kind, "uploaded", "{file_name} 写回了来源词"); - assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`"); - assert!( - shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS - .contains(&kind) - || kind == "asset", - "非 canonical kind: {kind}({file_name})" - ); - } } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs index 0896b32eb..d7fbc1ae9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -17,6 +17,8 @@ pub(crate) const AGC_COCOS_EDITOR_PLUGIN_ID: &str = "agc-cocos-editor"; /// 该插件在 Agent 侧对应的 Runtime 工具名。 pub(crate) const AGC_COCOS_EDITOR_TOOL_NAME: &str = "cocos.editor.execute"; +pub(crate) const AGC_UNITY_EDITOR_PLUGIN_ID: &str = "agc-unity-editor"; +pub(crate) const AGC_UNITY_EDITOR_TOOL_NAME: &str = "unity.editor.execute"; const STATE_FILE_NAME: &str = "builtin-plugins.json"; const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1"; @@ -24,31 +26,34 @@ const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum BuiltinPlugin { CocosEditor, + UnityEditor, } impl BuiltinPlugin { pub(crate) fn id(self) -> &'static str { match self { Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID, + Self::UnityEditor => AGC_UNITY_EDITOR_PLUGIN_ID, } } /// 未持久化任何开关时的默认状态。 fn default_enabled(self) -> bool { match self { - Self::CocosEditor => true, + Self::CocosEditor | Self::UnityEditor => true, } } /// 该插件是否向 Agent 暴露 Runtime 工具。 fn exposes_agent_tools(self) -> bool { match self { - Self::CocosEditor => true, + Self::CocosEditor | Self::UnityEditor => true, } } } -pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[BuiltinPlugin::CocosEditor]; +pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = + &[BuiltinPlugin::CocosEditor, BuiltinPlugin::UnityEditor]; pub(crate) fn builtin_plugin(id: &str) -> Option { BUILTIN_PLUGINS @@ -237,7 +242,16 @@ fn persist(guard: &BuiltinPluginState) -> Result<(), String> { /// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。 pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool { plugin.exposes_agent_tools() - && cfg!(all(windows, feature = "cocos-editor-execute")) + && match plugin { + BuiltinPlugin::CocosEditor => cfg!(all(windows, feature = "cocos-editor-execute")), + BuiltinPlugin::UnityEditor => { + cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )) && unity_editor_bridge::is_supported_platform() + } + } && is_enabled(plugin.id()) } @@ -245,18 +259,8 @@ pub(crate) fn cocos_editor_agent_tool_available() -> bool { agent_tool_available(BuiltinPlugin::CocosEditor) } -/// Cocos 编辑器插件只对当前确认为 Cocos Creator 的项目可用。 -/// -/// 项目类型以项目根的真实结构为准,不能仅凭插件开关或编译 feature 推断。 -pub(crate) fn cocos_editor_agent_tool_available_for_project(root: &Path) -> bool { - cocos_editor_agent_tool_available() - && crate::project::discover_local_cocos_project_root(root) - .ok() - .flatten() - .is_some() -} - pub(crate) fn available_agent_tools() -> Vec<&'static str> { + let mut available = Vec::new(); if cocos_editor_agent_tool_available() { let mut tools = vec![AGC_COCOS_EDITOR_TOOL_NAME]; tools.extend( @@ -264,19 +268,16 @@ pub(crate) fn available_agent_tools() -> Vec<&'static str> { .iter() .filter_map(|tool| tool["name"].as_str()), ); - tools - } else { - Vec::new() + available.extend(tools); } + if unity_editor_agent_tool_available() { + available.push(AGC_UNITY_EDITOR_TOOL_NAME); + } + available } -/// Project-scoped variant used by the isolated DirectProject MCP bridge. -/// Without a project root the safe result is an empty Cocos tool set. -pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str> { - if !cocos_editor_agent_tool_available_for_project(root) { - return Vec::new(); - } - available_agent_tools() +pub(crate) fn unity_editor_agent_tool_available() -> bool { + agent_tool_available(BuiltinPlugin::UnityEditor) } #[cfg(test)] @@ -294,6 +295,28 @@ mod tests { .unwrap_or_else(|error| error.into_inner()) } + #[test] + fn unity_tool_visibility_requires_platform_and_independent_toggle() { + let _guard = test_lock(); + let config = tempdir().unwrap(); + initialize(config.path()).unwrap(); + let supported = cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )); + assert_eq!( + available_agent_tools().contains(&AGC_UNITY_EDITOR_TOOL_NAME), + supported + ); + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap(); + assert_eq!(unity_editor_agent_tool_available(), supported); + set_enabled(AGC_UNITY_EDITOR_PLUGIN_ID, false).unwrap(); + assert!(!unity_editor_agent_tool_available()); + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap(); + assert!(!unity_editor_agent_tool_available()); + } + #[test] fn builtin_plugins_default_to_enabled_and_reject_unknown_ids() { let _guard = test_lock(); @@ -455,32 +478,4 @@ mod tests { tool_visible_when_enabled ); } - - #[test] - fn project_scoped_availability_requires_a_cocos_creator_root() { - let _guard = test_lock(); - let directory = tempdir().expect("temp config"); - initialize(directory.path()).expect("initialize"); - set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable"); - let non_cocos = tempdir().expect("non-cocos project"); - assert!(!cocos_editor_agent_tool_available_for_project( - non_cocos.path() - )); - - let cocos = tempdir().expect("cocos project"); - fs::write( - cocos.path().join("package.json"), - r#"{"creator":{"version":"3.8.8"}}"#, - ) - .expect("cocos package"); - fs::create_dir(cocos.path().join("assets")).expect("cocos assets"); - assert_eq!( - cocos_editor_agent_tool_available_for_project(cocos.path()), - cfg!(feature = "cocos-editor-execute") - ); - assert_eq!( - available_agent_tools_for_project(non_cocos.path()), - Vec::<&'static str>::new() - ); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 7006c3aff..3f61116b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -676,6 +676,21 @@ pub(crate) fn import_local_cocos_project( import_local_cocos_project_at(root, project_id.trim(), name.trim()) } +#[tauri::command] +pub(crate) fn import_local_unity_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + if discover_local_unity_project_root(root)?.is_none() { + return Err("所选目录不是有效的 Unity 项目".to_string()); + } + let _lock = acquire_project_write_lock(root, "project.create")?; + import_local_unity_project_at(root, project_id.trim(), name.trim()) +} + #[tauri::command] pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -737,6 +752,7 @@ pub(crate) fn inspect_local_project_directory_sync( let recent_run_trace = recent_game_creator_run_trace(root); let godot_project_root = discover_local_godot_project_root(root)?; let cocos_project_root = discover_local_cocos_project_root(root)?; + let unity_project_root = discover_local_unity_project_root(root)?; Ok(LocalProjectDirectoryStatus { project_path: root.to_string_lossy().into_owned(), exists: root.exists(), @@ -746,6 +762,8 @@ pub(crate) fn inspect_local_project_directory_sync( godot_project_root, is_cocos_project: cocos_project_root.is_some(), cocos_project_root, + is_unity_project: unity_project_root.is_some(), + unity_project_root, project_name: game_creator_project_name(root), modified_at: project_directory_modified_at(root), manifest_error: game_creator_project_manifest_error(root), @@ -2124,7 +2142,7 @@ pub(crate) fn register_local_asset( register_local_asset_at( root, local_path.trim(), - kind.trim(), + crate::assets::registration_asset_kind(&kind, "asset.register"), media_type.trim(), source_kind.trim(), GameCreationAppAssetSource { @@ -2154,14 +2172,8 @@ pub(crate) fn create_ui_design_resource( if manifest.project_id != expected_project_id.trim() { return Err("project-identity-conflict".to_string()); } - let next_index = manifest - .assets - .iter() - .filter(|asset| asset.kind == "UI") - .count() - + 1; - let resource_name = format!("UI 设计 {next_index}"); - let relative_path = format!("ui/{resource_name}.json"); + let (resource_name, relative_path) = + crate::ui_editor::resource_bridge::next_ui_design_path(root, &manifest)?; let absolute_path = resolve_local_project_path(root, &relative_path)?; if let Some(parent) = absolute_path.parent() { ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?; @@ -2191,13 +2203,16 @@ pub(crate) fn create_ui_design_resource( let asset = match register_local_asset_at( root, &relative_path, - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE, "generated", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, canvas_project_id: None, - resource_id: Some(format!("ui:{next_index}")), + resource_id: Some(format!( + "ui:{}", + resource_name.trim_start_matches("UI 设计 ") + )), asset_object_id: None, task_id: None, prompt: None, @@ -2381,7 +2396,7 @@ pub(crate) fn import_canvas_asset( import_canvas_asset_at( root, local_path.trim(), - kind.trim(), + registration_asset_kind(kind.trim(), "canvas.asset_import"), media_type.trim(), canvas_project_id.trim(), trim_optional_string(resource_id), @@ -2703,7 +2718,7 @@ pub(crate) fn import_ui_editor_local_fonts( let registered = register_local_asset_entry( root, &relative_path, - "font", + GameCreationAppAssetKind::Font, validated.metadata.format.media_type(), "font", GameCreationAppAssetSource { @@ -2815,7 +2830,7 @@ mod ui_editor_font_tests { let registered = register_local_asset_entry( root, relative_path, - "font", + GameCreationAppAssetKind::Font, "font/ttf", "font", GameCreationAppAssetSource { @@ -2918,12 +2933,12 @@ mod ui_editor_font_tests { symlink(fixture_font_path(), &target).expect("create font symlink"); let entry = GameCreationAppAssetManifestEntry { id: "font-linked".to_string(), - kind: "font".to_string(), + kind: GameCreationAppAssetKind::Font, media_type: "font/ttf".to_string(), local_path: relative_path.to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("font"), + category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Font), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Uploaded, @@ -3112,9 +3127,7 @@ mod agent_asset_import_tests { assert!(first.assets[4].local_path.ends_with(".html")); assert!(root.join(&first.assets[1].local_path).is_file()); // P0 回归:本地导入的图片只能落中性的 `image`(→ `unclassified` → 「待归类」)。 - // 曾经写成 `ui`,经 `ui → ui-design → ui-interaction` 把任意图片钉死在「UI 交互」栏, - // 且落盘 `category` 成了非 `unclassified` 值后读时自愈永远救不回来。 - // 把 `agent_local_project_file_type` 的图片分支改回 `"ui"` 必须让本用例变红。 + // 不能把任意图片钉死在「UI 交互」栏,落盘 category 也必须保持中性。 let manifest: serde_json::Value = serde_json::from_str( &fs::read_to_string(root.join(".agent/manifest.json")).expect("read imported manifest"), ) @@ -3132,8 +3145,8 @@ mod agent_asset_import_tests { assert!( !imported_assets .iter() - .any(|asset| asset["kind"] == "ui" || asset["category"] == "ui-interaction"), - "本地导入不得产出 `ui` / `ui-interaction`:{manifest}" + .any(|asset| asset["category"] == "ui-interaction"), + "本地导入不得产出 `ui-interaction`:{manifest}" ); let revision_after_first = read_game_creator_agent_runtime_project_revision(root) .expect("read imported revision") @@ -3353,31 +3366,43 @@ mod agent_asset_import_tests { .is_err()); } - /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**, - /// 判不出才退回中性 `image`(→ 「待归类」),**永远不许回退成常量 `ui`**。 + /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**只接受 canonical 值**, + /// 认不出的原值收口成 `unknown`(→ 「待归类」)并留痕原始串。 + /// 缺失/空白退回中性的 `image`。 /// - /// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回 `"ui"` 的实现, + /// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回固定值的实现, /// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。 #[test] fn imported_platform_asset_kind_prefers_payload_type_over_ui_default() { - assert_eq!(imported_platform_asset_kind(Some("character")), "character"); - assert_eq!(imported_platform_asset_kind(Some("scene")), "scene"); + use GameCreationAppAssetKind as Kind; + assert_eq!( + imported_platform_asset_kind(Some("character")), + Kind::Character + ); + assert_eq!(imported_platform_asset_kind(Some("scene")), Kind::Scene); assert_eq!( imported_platform_asset_kind(Some("character-animation")), - "character-animation" + Kind::CharacterAnimation ); - assert_eq!(imported_platform_asset_kind(Some("icon-spec")), "icon-spec"); - // 平台确实说它是 UI 设计稿时,才允许落 `ui-design`(→ 「UI 交互」); - // 大写 `UI` 与旧值 `ui` 都要归一到同一口径,但**不允许由我们替它默认**。 - assert_eq!(imported_platform_asset_kind(Some("UI")), "ui-design"); - assert_eq!(imported_platform_asset_kind(Some("ui")), "ui-design"); - // 缺失 / 空白 / 未知值一律落 canonical `image` 兜底(→ 「待归类」)。 - assert_eq!(imported_platform_asset_kind(None), "image"); - assert_eq!(imported_platform_asset_kind(Some(" ")), "image"); - assert_eq!(imported_platform_asset_kind(Some("mystery")), "image"); - // `kind` 是外部输入,原型链键必须走 `image` 兜底,不能取到原型上的值。 - assert_eq!(imported_platform_asset_kind(Some("__proto__")), "image"); - assert_eq!(imported_platform_asset_kind(Some("constructor")), "image"); + assert_eq!( + imported_platform_asset_kind(Some("icon-spec")), + Kind::IconSpec + ); + // 平台确实说它是 UI 设计稿时(`ui-design`)才落 `UiDesign`(→ 「UI 交互」)。 + assert_eq!( + imported_platform_asset_kind(Some("ui-design")), + Kind::UiDesign + ); + // 缺失 / 空白退回中性的 `image`(→ 「待归类」)。 + assert_eq!(imported_platform_asset_kind(None), Kind::Image); + assert_eq!(imported_platform_asset_kind(Some(" ")), Kind::Image); + for raw in ["UI", "mystery", "future-kind"] { + assert_eq!( + imported_platform_asset_kind(Some(raw)), + Kind::Unknown, + "{raw} 不是 canonical kind,不得被归一" + ); + } } /// 反查门禁:两处平台素材导入必须继续用 `imported_platform_asset_kind` 解析 `kind`, @@ -3906,7 +3931,7 @@ pub(crate) async fn list_editor_assets_for_agent_at( #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct AgentLocalProjectFileType { category: &'static str, - asset_kind: &'static str, + asset_kind: GameCreationAppAssetKind, media_type: &'static str, max_file_size: u64, } @@ -3961,14 +3986,14 @@ fn agent_local_project_file_type( // 错值放大成显式的 `ui-interaction`。 media_type.map(|media_type| AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type, max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }) } "ttf" | "otf" | "woff" | "woff2" => Some(AgentLocalProjectFileType { category: "font", - asset_kind: "font", + asset_kind: GameCreationAppAssetKind::Font, media_type: match extension.as_str() { "ttf" => "font/ttf", "otf" => "font/otf", @@ -3979,68 +4004,68 @@ fn agent_local_project_file_type( }), "mp3" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/mpeg", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "wav" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/wav", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "ogg" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/ogg", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "flac" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/flac", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "m4a" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/mp4", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "aac" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/aac", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "opus" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/opus", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "mp4" => Some(AgentLocalProjectFileType { category: "video", - asset_kind: "video", + asset_kind: GameCreationAppAssetKind::Video, media_type: "video/mp4", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "webm" => Some(AgentLocalProjectFileType { category: "video", - asset_kind: "video", + asset_kind: GameCreationAppAssetKind::Video, media_type: "video/webm", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "mov" => Some(AgentLocalProjectFileType { category: "video", - asset_kind: "video", + asset_kind: GameCreationAppAssetKind::Video, media_type: "video/quicktime", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml" | "csv" | "ini" | "conf" | "xml" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: if extension == "json" { "application/json" } else if matches!(extension.as_str(), "yaml" | "yml") { @@ -4057,7 +4082,7 @@ fn agent_local_project_file_type( | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some(AgentLocalProjectFileType { category: "code", - asset_kind: "code", + asset_kind: GameCreationAppAssetKind::Code, media_type: if matches!(extension.as_str(), "html" | "htm") { "text/html" } else if matches!(extension.as_str(), "css" | "scss" | "less") { @@ -4082,69 +4107,69 @@ fn agent_local_project_file_type( */ "scene" | "fire" | "prefab" | "terrain" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "tmx" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "application/xml", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "anim" | "animation" | "animgraph" | "animgraphvari" | "animask" => { Some(AgentLocalProjectFileType { category: "document", - asset_kind: "character-animation", + asset_kind: GameCreationAppAssetKind::CharacterAnimation, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }) } "mtl" | "material" | "pmtl" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "code", + asset_kind: GameCreationAppAssetKind::Code, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "effect" | "chunk" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "code", + asset_kind: GameCreationAppAssetKind::Code, media_type: "text/plain", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "plist" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: "application/xml", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "labelatlas" | "pac" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "fnt" | "atlas" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: "text/plain", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "glb" => Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "model/gltf-binary", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "gltf" => Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "model/gltf+json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "fbx" => Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "application/octet-stream", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), @@ -4153,51 +4178,51 @@ fn agent_local_project_file_type( // JSON、但也存在二进制变体,因此只按「非空」校验;能不能当文本预览由 // 结构化预览读取自己判定(非 UTF-8 时降级成类型卡)。 category: "binary", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" => { Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: "application/octet-stream", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }) } "psd" | "znt" => Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "application/octet-stream", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "tga" => Some(AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "image/x-tga", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "tif" | "tiff" => Some(AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "image/tiff", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "hdr" => Some(AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "image/vnd.radiance", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "exr" => Some(AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "image/x-exr", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "pcm" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/pcm", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), @@ -4309,7 +4334,7 @@ pub(crate) fn import_local_project_assets_for_agent( } inputs.push(( local_path, - file_type.asset_kind.to_string(), + file_type.asset_kind, file_type.media_type.to_string(), bytes, )); @@ -4328,7 +4353,7 @@ pub(crate) fn import_local_project_assets_for_agent( imported.push(ImportedAsset { id: existing.id.clone(), local_path: existing.local_path.clone(), - asset_kind: Some(existing.kind.clone()), + asset_kind: Some(existing.kind.to_string()), }); continue; } @@ -4367,7 +4392,7 @@ pub(crate) fn import_local_project_assets_for_agent( let registered = register_local_asset_entry( root, &local_path, - &asset_kind, + asset_kind, &media_type, "local", GameCreationAppAssetSource { @@ -4386,7 +4411,7 @@ pub(crate) fn import_local_project_assets_for_agent( imported.push(ImportedAsset { id: registered.id, local_path: registered.local_path, - asset_kind: Some(asset_kind), + asset_kind: Some(asset_kind.to_string()), }); advance_agent_runtime_project_revision_locked(root).map_err(|error| { format!("reconciliation-required: 本地资源已登记,但项目 revision 未能推进:{error}") @@ -4395,24 +4420,16 @@ pub(crate) fn import_local_project_assets_for_agent( Ok(RemoteImportResult { assets: imported }) } -/// 平台素材导入落盘的 manifest `kind`:**有真实类型就用真实类型**,判不出才退回中性的 -/// `image`(派生 `unclassified` → 「待归类」)。 -/// -/// 这里绝不允许回退成 `ui`:账户素材库记录与网页项目画布响应本来就带 `assetKind` -/// (`AgentEditorAssetRecord::asset_kind` / 响应字段 `assetKind`),把它换成常量等于丢掉 -/// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏 -/// (`ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值, -/// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。 -fn imported_platform_asset_kind(platform_kind: Option<&str>) -> String { - match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) { - // 平台侧词汇可能与 canonical 目录不完全一致(例如大写 `UI`),统一过 canonical 归一, - // 保证写入侧只产出 canonical `kind`。 - Some(kind) => { - shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind) - .to_string() - } - None => "image".to_string(), - } +/// 平台素材导入落盘的 manifest `kind`:缺失时使用中性的 `image`,其它输入只走共享 +/// `GameCreationAppAssetKind` 的严格解析,无法识别则得到 `Unknown` 并保留原始值留痕。 +fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind { + // 平台是外部系统,但 kind 口径**只有一条**:严格解析 + 非 canonical 原值留痕 + // (回调已由壳层注册成 `app_log!`,见 `main.rs`);缺失 / 空白按「没有信息」落 + // 中性 `image`。认不出的值就是 `Unknown` → 「待归类」, + // 原值只出现在日志里。 + platform_kind + .map(|kind| crate::assets::registration_asset_kind(kind, "platform.asset_kind")) + .unwrap_or(GameCreationAppAssetKind::Image) } /// 按账户素材 `assetId` 查询权威素材、换签下载并登记到当前项目。模型只提交 @@ -4519,7 +4536,7 @@ pub(crate) async fn import_account_editor_assets_for_agent( imported.push(ImportedAsset { id: existing.id.clone(), local_path: existing.local_path.clone(), - asset_kind: Some(existing.kind.clone()), + asset_kind: Some(existing.kind.to_string()), }); continue; } @@ -4569,7 +4586,7 @@ pub(crate) async fn import_account_editor_assets_for_agent( let registered = register_local_asset_entry( root, &local_path, - &asset_kind, + asset_kind, &media_type, "canvas", GameCreationAppAssetSource { @@ -4687,7 +4704,7 @@ pub(crate) async fn import_ui_editor_remote_assets( let registered = register_local_asset_entry( root, &local_path, - &asset_kind, + asset_kind, &media_type, "canvas", GameCreationAppAssetSource { @@ -4886,7 +4903,7 @@ pub(crate) fn prepare_local_project_asset_generation( "1K", "图片尺寸", )?, - asset_kind: asset_kind.to_string(), + asset_kind, asset_label: local_project_asset_single_line( asset_name, LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS, @@ -4895,9 +4912,9 @@ pub(crate) fn prepare_local_project_asset_generation( .unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()), replace_existing: false, slice_count: None, - // 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 art-spritesheet + // 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 icon-spritesheet // 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。 - slice_mode: (asset_kind == "art-spritesheet") + slice_mode: (asset_kind == GameCreationAppAssetKind::IconSpritesheet) .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, @@ -4976,17 +4993,14 @@ mod local_project_asset_generation_tests { for kind in [ "image", "character", - "spec", "icon-spec", - "ui-prototype", - "art-spritesheet", + "ui-design", + "icon-spritesheet", ] { let request = prepare(kind, "像素月光厨房主角").expect("toolbar kind is supported"); assert_eq!(request.root, PathBuf::from("/tmp/project")); assert_eq!(request.prompt, "像素月光厨房主角"); - // `spec` 只放行到权威类型;其余 kind 原样进入参数化通道。 - let expected = if kind == "spec" { "icon-spec" } else { kind }; - assert_eq!(request.options.asset_kind, expected); + assert_eq!(request.options.asset_kind.as_str(), kind); assert!(!request.options.replace_existing); assert_eq!(request.options.slice_count, None); } @@ -5002,7 +5016,7 @@ mod local_project_asset_generation_tests { let explicit = prepare_local_project_asset_generation( " /tmp/project ", - " art-spritesheet ", + "icon-spritesheet", " 像素图集 ", Some("16:9"), Some("2K"), @@ -5014,7 +5028,10 @@ mod local_project_asset_generation_tests { .expect("explicit options"); assert_eq!(explicit.root, PathBuf::from("/tmp/project")); assert_eq!(explicit.prompt, "像素图集"); - assert_eq!(explicit.options.asset_kind, "art-spritesheet"); + assert_eq!( + explicit.options.asset_kind, + GameCreationAppAssetKind::IconSpritesheet + ); assert_eq!(explicit.options.aspect_ratio, "16:9"); assert_eq!(explicit.options.image_size, "2K"); assert_eq!(explicit.options.asset_label, "主角图集"); @@ -5056,8 +5073,8 @@ mod local_project_asset_generation_tests { "生成要求不能为空" ); assert_eq!( - prepare("game-art", "要求").expect_err("unverified kind"), - "素材类型不受支持:game-art" + prepare("future-kind", "要求").expect_err("unverified kind"), + "素材类型不受支持:future-kind" ); // 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。 for rejected in ["version", "all", "bogus", "UI"] { @@ -5115,7 +5132,7 @@ mod local_project_asset_generation_tests { ); assert_eq!( prepare( - "spec", + "icon-spec", &"x".repeat(LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS + 1) ) .expect_err("oversized prompt"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index 4c5cf1444..ba8cb2143 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -11,12 +11,339 @@ use std::path::PathBuf; use tauri::Manager; use crate::plugin_host::PluginHost; +use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; +use serde_json::{json, Value}; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; + +struct UnityPendingDelivery { + id: String, + outcome_known: bool, +} + +fn unity_pending_delivery() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(None)) +} + +pub(crate) fn unity_execution_fence_path(config_dir: &Path) -> std::path::PathBuf { + config_dir.join("unity-editor-execution.pending") +} + +pub(crate) fn unity_uncertain_fence_path(config_dir: &Path) -> std::path::PathBuf { + config_dir.join("unity-editor-execution.uncertain") +} + +pub(crate) fn mark_unity_execution_uncertain_at(config_dir: &Path) -> Result<(), String> { + use std::io::Write; + let path = unity_uncertain_fence_path(config_dir); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + { + Ok(mut file) => file + .write_all(b"needs-reconciliation") + .and_then(|_| file.sync_all()) + .map_err(|_| "无法持久记录 Unity 执行不确定状态".to_string()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(_) => Err("无法持久记录 Unity 执行不确定状态".to_string()), + } +} + +pub(crate) fn mark_unity_execution_uncertain() -> Result<(), String> { + let config = crate::game_creator_runtime_config_dir_lock() + .lock() + .map_err(|_| "Unity 配置锁损坏")? + .clone() + .ok_or("Unity 执行宿主尚未初始化")?; + mark_unity_execution_uncertain_at(&config) +} + +fn current_unity_execution_fence() -> Result { + crate::game_creator_runtime_config_dir_lock() + .lock() + .map_err(|_| "Unity 配置锁损坏")? + .as_deref() + .map(unity_execution_fence_path) + .ok_or_else(|| "Unity 执行宿主尚未初始化".to_string()) +} + +fn remove_unity_execution_fence(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("无法清理 Unity 执行确认记录,继续阻断执行".to_string()), + } +} + +/// 调用方必须同时独占 GUI 参与锁及 Runner 实例锁,保证这是全部宿主退出后的首次打开。 +pub(crate) fn reset_unity_execution_fence_for_fresh_gui(config_dir: &Path) -> Result<(), String> { + remove_unity_execution_fence(&unity_execution_fence_path(config_dir))?; + remove_unity_execution_fence(&unity_uncertain_fence_path(config_dir)) +} + +pub(crate) fn unity_execute_receipt_is_valid(value: &Value) -> bool { + if value["retryAllowed"] != false { + return false; + } + let valid_error = value["error"]["code"] + .as_str() + .is_some_and(|code| !code.trim().is_empty()) + && value["error"]["message"] + .as_str() + .is_some_and(|message| !message.trim().is_empty()); + match value["status"].as_str() { + Some("completed") => { + value["ok"] == true && value["dispatched"] == true && value.get("result").is_some() + } + Some("failed") => value["ok"] == false && value["dispatched"].is_boolean() && valid_error, + Some("needs-reconciliation") => { + value["ok"] == false && value["dispatched"] == true && valid_error + } + _ => false, + } +} + +fn unity_reconciliation(message: &str) -> Value { + json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"needs-reconciliation","message":message}}) +} + +pub(crate) fn unity_not_dispatched(message: &str) -> Value { + json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false,"error":{"code":"not-dispatched","message":message}}) +} + +/// 仅在长寿命 Runner 中触达 native service,GUI / Runtime / DirectProject 共用此入口。 +pub(crate) fn unity_editor_rpc(method: &str, params: Value) -> Result { + let method = method.strip_prefix("editor.").unwrap_or(method); + if crate::runner::external_agent_runner_is_server_process() { + unity_editor_rpc_owned(method, params, None) + } else { + crate::runner::call_external_unity_editor(method, params) + } +} + +pub(crate) fn execute_unity_editor_code(root: &Path, code: &str) -> Result { + unity_editor_rpc( + "execute", + json!({"projectPath":root.to_string_lossy(),"code":code,"timeoutMs":60000}), + ) +} + +/// GUI 读不到执行回执时不会发送 ack;该门闩不能被插件、连接或项目生命周期清除。 +pub(crate) fn unity_editor_rpc_owned( + method: &str, + params: Value, + delivery_id: Option<&str>, +) -> Result { + let method = method.strip_prefix("editor.").unwrap_or(method); + if !matches!( + method, + "detect" | "connect" | "status" | "execute" | "disconnect" + ) { + return Err("Unity RPC 方法不受支持".to_string()); + } + if method == "disconnect" { + unity_editor_bridge::disconnect_unity_editor(); + return Ok( + json!({"adapter":"unity-editor","connected":false,"pid":null,"projectPath":params.get("projectPath"),"version":null}), + ); + } + if method == "connect" { + unity_editor_bridge::disconnect_unity_editor(); + } + params + .get("projectPath") + .and_then(Value::as_str) + .ok_or("缺少 projectPath")?; + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("Unity 插件不可用".to_string()); + } + let mut delivery = if method == "execute" { + let mut pending = match unity_pending_delivery().try_lock() { + Ok(pending) => pending, + Err(std::sync::TryLockError::WouldBlock) => { + return Ok(unity_not_dispatched( + "Unity 编辑器已有请求正在执行,请等待回执", + )) + } + Err(std::sync::TryLockError::Poisoned(_)) => { + return Ok(unity_reconciliation("Unity 执行状态异常,请人工核对")) + } + }; + let fence = current_unity_execution_fence()?; + let uncertain_fence = fence.with_extension("uncertain"); + if uncertain_fence.exists() { + return Ok(unity_reconciliation( + "Unity 执行回执未确认,请核对后退出全部宿主再重新打开", + )); + } + if pending + .as_ref() + .is_some_and(|pending| pending.outcome_known) + { + return Ok(unity_not_dispatched( + "Unity 上一条执行正在等待客户端确认回执", + )); + } + if pending.is_some() || fence.exists() { + return Ok(unity_reconciliation( + "先前 Unity 执行回执尚未确认,退出全部 AGC 和 Runner 后重新打开才可恢复", + )); + } + let id = delivery_id + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&fence) + .map_err(|_| "无法独占保存 Unity 执行确认记录,未发送请求")?; + file.write_all(id.as_bytes()) + .and_then(|_| file.sync_all()) + .map_err(|_| "无法持久保存 Unity 执行确认记录,未发送请求")?; + *pending = Some(UnityPendingDelivery { + id, + outcome_known: false, + }); + Some(pending) + } else { + None + }; + let result = unity_editor_bridge::UnityEditorAdapter::new(Vec::new()).rpc(method, params); + if method == "execute" { + // native 的 Err 均为发送前失败;发送后的未知状态由结构化 result 携带并锁存。 + let mut result = result.unwrap_or_else(|error| json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false,"error":{"code":"not-dispatched","message":error}})); + if !unity_execute_receipt_is_valid(&result) { + result = unity_reconciliation("Unity 原生执行回执格式损坏,禁止自动重发"); + } + let known = result["status"] != "needs-reconciliation"; + // 与本次 pending 写入同一临界区决定确认,避免返回后再次抢锁造成误判。 + if delivery_id.is_some() { + result["ackRequired"] = json!(known); + } + if let Some(pending) = delivery.as_mut() { + if let Some(pending) = pending.as_mut() { + pending.outcome_known = known; + } + if delivery_id.is_none() && known { + if current_unity_execution_fence() + .and_then(|path| remove_unity_execution_fence(&path)) + .is_err() + { + return Ok(unity_reconciliation( + "Unity 执行已返回,但确认记录无法提交,请人工核对", + )); + } + **pending = None; + } + } + return Ok(result); + } + result +} + +pub(crate) fn acknowledge_unity_editor_delivery(request_id: &str) -> Result<(), String> { + let mut pending = unity_pending_delivery() + .try_lock() + .map_err(|_| "Unity 执行尚未结束")?; + if !pending + .as_ref() + .is_some_and(|pending| pending.id == request_id && pending.outcome_known) + { + return Err("Unity 回执确认身份不匹配或执行结果仍不确定".to_string()); + } + remove_unity_execution_fence(¤t_unity_execution_fence()?)?; + *pending = None; + Ok(()) +} + +#[cfg(test)] +pub(crate) fn unity_delivery_requires_ack(request_id: &str) -> bool { + unity_pending_delivery() + .try_lock() + .ok() + .is_some_and(|pending| { + pending + .as_ref() + .is_some_and(|pending| pending.id == request_id && pending.outcome_known) + }) +} + +pub(crate) fn disconnect_unity_editor_connection() { + if crate::runner::external_agent_runner_is_server_process() { + unity_editor_bridge::disconnect_unity_editor(); + } else { + let _ = crate::runner::disconnect_external_unity_editor(); + } +} + +/// GUI 只代理已有 Runner RPC,不创建第二份 helper 或不确定门闩。 +struct RunnerUnityEditorAdapter; + +impl EditorAdapter for RunnerUnityEditorAdapter { + fn id(&self) -> &'static str { + "unity-editor" + } + fn detect(&self, project_path: &Path) -> Result { + serde_json::from_value(unity_editor_rpc( + "detect", + json!({"projectPath":project_path.to_string_lossy()}), + )?) + .map_err(|_| "Unity 探测回执格式无效".to_string()) + } + fn connect( + &mut self, + pid: u32, + project_path: &Path, + _version: &str, + ) -> Result { + serde_json::from_value(unity_editor_rpc( + "connect", + json!({"processId":pid,"projectPath":project_path.to_string_lossy()}), + )?) + .map_err(|_| "Unity 连接回执格式无效".to_string()) + } + fn disconnect(&mut self) { + disconnect_unity_editor_connection(); + } + fn translate_rpc(&self, method: &str, params: Value) -> Result { + unity_editor_bridge::UnityEditorAdapter::new(Vec::new()).translate_rpc(method, params) + } + fn rpc(&self, method: &str, params: Value) -> Result { + unity_editor_rpc(method, params) + } +} /// 随包 payload 相对资源根目录的位置,与 `tauri.windows.conf.json` 的资源映射保持一致。 #[allow(dead_code)] pub(crate) const COCOS_BRIDGE_PAYLOAD_RELATIVE: &str = "plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"; +#[allow(dead_code)] +pub(crate) const UNITY_ATTACH_HELPER_RELATIVE: &str = + "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe"; + +/// Runner 没有 GUI setup,仍只从随包位置或编译期工作区取受控 helper。 +pub(crate) fn configure_unity_helper_for_runtime() -> Result<(), String> { + let mut candidates = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join(UNITY_ATTACH_HELPER_RELATIVE)); + } + } + #[cfg(debug_assertions)] + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .ok_or("插件工作区目录不可用")? + .join(UNITY_ATTACH_HELPER_RELATIVE), + ); + unity_editor_bridge::configure_helper_candidates(candidates) +} + pub(crate) fn register_linked_editor_adapters( app: &tauri::AppHandle, host: &PluginHost, @@ -31,6 +358,10 @@ pub(crate) fn register_linked_editor_adapters( { let _ = (app, host); } + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + { + host.register_editor_adapter(Box::new(RunnerUnityEditorAdapter))?; + } Ok(()) } @@ -52,3 +383,91 @@ fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { } candidates } + +#[cfg(test)] +mod unity_receipt_tests { + use super::*; + + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + #[test] + fn unity_delivery_ack_distinguishes_busy_disabled_and_known_pre_dispatch_failure() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + for directory in ["Assets", "Packages", "ProjectSettings"] { + std::fs::create_dir(project.path().join(directory)).unwrap(); + } + std::fs::write( + project.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.1f1", + ) + .unwrap(); + let previous_config = crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() + .clone(); + *crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() = Some(config.path().to_path_buf()); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let params = json!({"projectPath":project.path().to_string_lossy(),"code":"return 2;"}); + { + let _busy = unity_pending_delivery().lock().unwrap(); + let result = + unity_editor_rpc_owned("execute", params.clone(), Some("busy-request")).unwrap(); + assert_eq!(result["status"], "failed"); + assert_eq!(result["dispatched"], false); + assert!(!unity_delivery_requires_ack("busy-request")); + } + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + false, + ) + .unwrap(); + assert!( + unity_editor_rpc_owned("execute", params.clone(), Some("disabled-request")).is_err() + ); + assert!(!unity_delivery_requires_ack("disabled-request")); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + true, + ) + .unwrap(); + // 空代码在 native 发送前失败,但本次 Runner delivery 已建 fence,仍须确认回执。 + let result = unity_editor_rpc_owned( + "execute", + json!({"projectPath":project.path().to_string_lossy(),"code":""}), + Some("known-failure"), + ) + .unwrap(); + assert_eq!(result["status"], "failed"); + assert_eq!(result["dispatched"], false); + assert_eq!(result["ackRequired"], true); + assert!(unity_delivery_requires_ack("known-failure")); + assert!(acknowledge_unity_editor_delivery("wrong-id").is_err()); + assert!(unity_execution_fence_path(config.path()).exists()); + acknowledge_unity_editor_delivery("known-failure").unwrap(); + assert!(!unity_execution_fence_path(config.path()).exists()); + *crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() = previous_config; + } + + #[test] + fn unity_ack_requires_complete_consistent_execution_receipt() { + assert!(unity_execute_receipt_is_valid( + &json!({"status":"completed","ok":true,"dispatched":true,"retryAllowed":false,"result":null}) + )); + assert!(unity_execute_receipt_is_valid( + &json!({"status":"failed","ok":false,"dispatched":false,"retryAllowed":false,"error":{"code":"missing-helper","message":"no helper"}}) + )); + for value in [ + json!({"status":"completed","ok":true}), + json!({"status":"completed","ok":false,"dispatched":true,"retryAllowed":false,"result":1}), + json!({"status":"failed","ok":false,"dispatched":true,"retryAllowed":false}), + json!({"status":"needs-reconciliation","ok":false,"dispatched":false,"retryAllowed":false,"error":"lost"}), + ] { + assert!(!unity_execute_receipt_is_valid(&value)); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs index d8df54e84..d523dc20a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs @@ -45,6 +45,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ "command.start", "command.stdin", "cocos.editor.execute", + "unity.editor.execute", "preview.start", "agent.delegate", "agent.spawn_isolated", @@ -66,6 +67,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[ "command.start", "command.stdin", "cocos.editor.execute", + "unity.editor.execute", "preview.start", "agent.delegate", "agent.spawn_isolated", 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 1db48e770..79672d028 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -35,8 +35,8 @@ use shared_contracts::game_creation_app::{ GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, - GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, - GameCreationAppAssetSourceKind, GameCreationAppCommandRunState, + GameCreationAppAgentGroup, GameCreationAppAssetKind, GameCreationAppAssetManifestEntry, + GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState, GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, @@ -69,6 +69,36 @@ macro_rules! app_log { }}; } +/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。 +/// +/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, +/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, +/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。 +/// +/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; +/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 +/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。 +fn register_non_canonical_asset_kind_reporter() { + static REPORTED: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter( + |raw_kind: &str, context: &str| { + let key = format!("{context}\u{1}{raw_kind}"); + let first_seen = REPORTED + .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) + .lock() + .map(|mut reported| reported.insert(key)) + .unwrap_or(true); + if !first_seen { + return; + } + app_log!( + "GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} context={context}" + ); + }, + ); +} + // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 mod agent; @@ -276,6 +306,8 @@ struct LocalProjectDirectoryStatus { godot_project_root: Option, is_cocos_project: bool, cocos_project_root: Option, + is_unity_project: bool, + unity_project_root: Option, project_name: Option, modified_at: Option, manifest_error: Option, @@ -2267,6 +2299,7 @@ mod async_runtime_stack_tests { #[cfg(not(test))] fn main() { install_agent_runtime_async_runtime_with_deep_stack(); + register_non_canonical_asset_kind_reporter(); let mut args = std::env::args().skip(1).collect::>(); if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { std::process::exit(exit_code); @@ -2505,9 +2538,11 @@ fn main() { create_automatic_local_game_project_from_template, init_local_game_project, fetch_game_template_library, + get_game_template_library_access, download_game_template, import_local_godot_project, import_local_cocos_project, + import_local_unity_project, is_local_project_directory_non_empty, inspect_local_project_directory, pick_local_project_directory, @@ -2684,6 +2719,7 @@ fn main() { ack_error_reports, sync_local_project_snapshot, read_local_project_snapshot_state, + set_active_project_snapshot_workspace, ]) .build(tauri_context); let app = match app { diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 389d0486d..6c8ab9099 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -767,22 +767,58 @@ fn permission_for_method(method: &str) -> Option<&'static str> { } } -fn has_cocos_editor_adapter(editors: &EditorRegistry) -> Result { +fn has_editor_adapter(editors: &EditorRegistry, adapter: &str) -> Result { Ok(editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())? - .contains_key("cocos-editor")) + .contains_key(adapter)) } fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), String> { - if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !has_cocos_editor_adapter(editors)? - { - return Err("当前客户端不支持 Cocos 编辑器桥接".to_string()); + let adapter = match id { + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID => "cocos-editor", + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => "unity-editor", + _ => return Ok(()), + }; + if !has_editor_adapter(editors, adapter)? { + let name = if adapter == "cocos-editor" { + "Cocos" + } else { + "Unity" + }; + return Err(format!("当前客户端不支持 {name} 编辑器桥接")); } Ok(()) } +fn controlled_editor_params(project: &Path, mut params: Value) -> Result { + if params.is_null() { + params = json!({}); + } + let params = params + .as_object_mut() + .ok_or_else(|| "编辑器参数必须是对象".to_string())?; + let canonical = project + .canonicalize() + .map_err(|_| "当前项目目录不可读".to_string())?; + if let Some(explicit) = params.get("projectPath") { + let explicit = explicit + .as_str() + .ok_or_else(|| "projectPath 必须是字符串".to_string())?; + let explicit = Path::new(explicit) + .canonicalize() + .map_err(|_| "编辑器项目目录不可读".to_string())?; + if explicit != canonical { + return Err("编辑器 projectPath 必须匹配当前受控项目".to_string()); + } + } + params.insert( + "projectPath".to_string(), + json!(canonical.to_string_lossy()), + ); + Ok(Value::Object(params.clone())) +} + impl PluginHost { pub(crate) fn initialize(&self, config_dir: &Path) -> Result<(), String> { let root = plugin_root(config_dir)?; @@ -953,24 +989,10 @@ impl PluginHost { .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; self.scan_locked(&mut state, &root)?; - let cocos_project = state - .active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }); - let cocos_available = cocos_project && has_cocos_editor_adapter(&state.editors)?; state .plugins .values() - .filter(|record| { - record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_available - }) + .filter(|record| require_plugin_adapter(&record.id, &state.editors).is_ok()) .map(|record| self.summary_locked(record)) .collect() } @@ -1035,20 +1057,6 @@ impl PluginHost { .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let active_project = state.active_project.clone(); require_plugin_adapter(id, &state.editors)?; - if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }) - { - return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); - } let editors = state.editors.clone(); let record = state .plugins @@ -1128,6 +1136,9 @@ impl PluginHost { } if !enabled { let _ = self.stop(id); + if id == crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID { + crate::editor_adapters::disconnect_unity_editor_connection(); + } } crate::builtin_plugins::set_enabled(id, enabled)?; self.refresh() @@ -1147,21 +1158,6 @@ impl PluginHost { .plugins .get(id) .ok_or_else(|| "插件不存在".to_string())?; - if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !state - .active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }) - { - return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); - } if record.running.is_none() || !record.manifest.permissions.contains("ui.register") { return Err("插件面板未激活".to_string()); } @@ -1207,21 +1203,6 @@ impl PluginHost { .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; - if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !state - .active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }) - { - return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); - } let record = state .plugins .get_mut(id) @@ -1260,6 +1241,8 @@ impl PluginHost { ) }; let deadline = Instant::now() + response_timeout; + let unity_execute = id == crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID + && method == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME; let (write_sender, write_receiver) = mpsc::channel(); thread::spawn(move || { let _ = write_sender.send(write_rpc_shared( @@ -1267,14 +1250,18 @@ impl PluginHost { &json!({"jsonrpc":"2.0", "id":request_id, "method":method, "params":params}), )); }); + let mut command_sent = false; let result = match write_receiver.recv_timeout(RPC_TIMEOUT) { - Ok(Ok(())) => match response_receiver - .recv_timeout(deadline.saturating_duration_since(Instant::now())) - { - Ok(result) => result, - Err(RecvTimeoutError::Timeout) => Err("插件 RPC 响应超时".to_string()), - Err(RecvTimeoutError::Disconnected) => Err("插件进程已退出".to_string()), - }, + Ok(Ok(())) => { + command_sent = true; + match response_receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(result) => result, + Err(RecvTimeoutError::Timeout) => Err("插件 RPC 响应超时".to_string()), + Err(RecvTimeoutError::Disconnected) => Err("插件进程已退出".to_string()), + } + } Ok(Err(error)) => Err(error), Err(_) => { self.terminate_rpc_instance(id, &pending); @@ -1284,6 +1271,16 @@ impl PluginHost { if let Ok(mut pending) = pending.lock() { pending.remove(&request_id); } + if unity_execute + && command_sent + && !result.as_ref().is_ok_and(|value| { + crate::editor_adapters::unity_execute_receipt_is_valid(value) + && value["status"] != "needs-reconciliation" + }) + { + // Unity 已知结果到 JS / 调用者的最后一跳丢失同样不可通过重载插件重试。 + let _ = crate::runner::mark_external_unity_editor_uncertain(); + } audit( &root, id, @@ -1555,32 +1552,36 @@ impl PluginHost { } "host.rpc" => { require_plugin_adapter(&manifest.id, editors)?; - let input: EditorRpcInput = descriptor_from_params(params)?; - if manifest.id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }) - { - return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); + if crate::builtin_plugins::toggle_state(&manifest.id) == Some(false) { + return Err("插件已禁用".to_string()); } - let adapter = input + let input: EditorRpcInput = descriptor_from_params(params)?; + let adapter = manifest .adapter - .or_else(|| manifest.adapter.clone()) + .as_deref() .ok_or_else(|| "插件未指定编辑器适配器".to_string())?; + if input + .adapter + .as_deref() + .is_some_and(|requested| requested != adapter) + { + return Err("插件不能覆盖 manifest 声明的编辑器适配器".to_string()); + } + // 不在全局锁后排队,避免外层已超时的写操作稍后才派发。 let editors = editors - .lock() - .map_err(|_| "编辑器注册表锁已损坏".to_string())?; + .try_lock() + .map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?; let editor = editors - .get(&adapter) + .get(adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))?; - editor.rpc(&input.method, input.params) + let project = active_project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())?; + let project = project + .as_deref() + .ok_or_else(|| "尚未设置当前项目".to_string())?; + let params = controlled_editor_params(project, input.params)?; + editor.rpc(&input.method, params) } _ => Err(format!("宿主不支持 RPC 方法:{method}")), } @@ -1626,7 +1627,7 @@ impl PluginHost { } pub(crate) fn set_active_project(&self, project_path: Option) -> Result<(), String> { - let mut state = self + let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; @@ -1640,30 +1641,25 @@ impl PluginHost { .map_err(|_| "项目目录不可读".to_string()) }) .transpose()?; - let is_cocos_project = project.as_deref().is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }); + let previous = state + .active_project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())? + .clone(); + if previous != project { + let mut editors = state + .editors + .try_lock() + .map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?; + if let Some(editor) = editors.get_mut("cocos-editor") { + editor.disconnect(); + } + crate::editor_adapters::disconnect_unity_editor_connection(); + } *state .active_project .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? = project; - if !is_cocos_project { - if let Some(record) = state - .plugins - .get_mut(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) - { - if let Some(mut running) = record.running.take() { - let _ = running.child.kill(); - let _ = running.child.wait(); - } - if record.manifest.enabled { - record.status = "stopped".to_string(); - } - } - } + .map_err(|_| "项目上下文锁已损坏".to_string())? = project.clone(); for record in state.plugins.values() { if let Some(running) = record.running.as_ref() { let subscribed = running @@ -1706,7 +1702,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; if editors.contains_key(adapter.id()) { return Err("编辑器适配器已注册".to_string()); @@ -1726,7 +1722,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get(&adapter) @@ -1747,7 +1743,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get_mut(&adapter) @@ -1762,7 +1758,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get_mut(&adapter) @@ -1783,7 +1779,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get(&adapter) @@ -1871,16 +1867,21 @@ pub(crate) fn read_agc_plugin_panel( } #[tauri::command] -pub(crate) fn set_agc_plugin_project_path( +pub(crate) async fn set_agc_plugin_project_path( project_path: Option, - host: State<'_, PluginHost>, + app: tauri::AppHandle, ) -> Result<(), String> { - host.set_active_project(project_path) + tauri::async_runtime::spawn_blocking(move || { + app.state::().set_active_project(project_path) + }) + .await + .map_err(|_| "切换插件项目上下文任务失败".to_string())? } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::Ordering; use tempfile::tempdir; fn manifest() -> PluginManifest { @@ -2096,7 +2097,190 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p assert!(read_bounded_rpc_line(&mut reader).is_err()); } - struct StubCocosAdapter; + #[test] + fn editor_rpc_enforces_manifest_adapter_and_current_project_without_queueing() { + let root = tempdir().unwrap(); + let other = tempdir().unwrap(); + let project = Arc::new(Mutex::new(Some(root.path().to_path_buf()))); + let editors: EditorRegistry = Arc::new(Mutex::new(BTreeMap::from([( + "cocos-editor".to_string(), + Box::new(StubCocosAdapter::default()) as Box, + )]))); + let registrations = Arc::new(Mutex::new(PluginRegistrations::default())); + let mut manifest = manifest(); + manifest.adapter = Some("cocos-editor".to_string()); + manifest.permissions.insert("editor.rpc".to_string()); + let call = |params| { + PluginHost::handle_host_request( + root.path(), + &project, + &editors, + &manifest, + ®istrations, + "host.rpc", + Some(params), + ) + }; + assert!(call(json!({"adapter":"unity-editor","method":"editor.execute","params":{"code":"return 1;"}})).unwrap_err().contains("manifest")); + assert!(call(json!({"method":"editor.execute","params":{"projectPath":other.path(),"code":"return 1;"}})).unwrap_err().contains("受控项目")); + let result = + call(json!({"method":"editor.execute","params":{"code":"return 1;"}})).unwrap(); + assert_eq!( + result["params"]["projectPath"], + root.path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + let _busy = editors.lock().unwrap(); + let start = std::time::Instant::now(); + assert!(call(json!({"method":"editor.execute","params":{"code":"return 1;"}})).is_err()); + assert!(start.elapsed() < Duration::from_millis(100)); + } + + #[derive(Default)] + struct StubCocosAdapter { + disconnects: Arc, + } + + struct StubUnityAdapter; + + impl EditorAdapter for StubUnityAdapter { + fn id(&self) -> &'static str { + "unity-editor" + } + fn detect(&self, _project_path: &Path) -> Result { + Ok(EditorConnectionInfo::disconnected("unity-editor")) + } + fn connect( + &mut self, + _pid: u32, + _project_path: &Path, + _version: &str, + ) -> Result { + Err("test does not connect".to_string()) + } + fn disconnect(&mut self) {} + fn translate_rpc(&self, _method: &str, params: Value) -> Result { + Ok(params) + } + fn rpc(&self, _method: &str, params: Value) -> Result { + Ok( + json!({"ok":true,"status":"completed","retryAllowed":false,"dispatched":true,"result":{"code":params["code"],"projectPath":params["projectPath"]}}), + ) + } + } + + #[test] + fn workspace_unity_plugin_round_trips_across_project_contexts() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempdir().unwrap(); + let project = tempdir().unwrap(); + for directory in ["Assets", "Packages", "ProjectSettings"] { + fs::create_dir(project.path().join(directory)).unwrap(); + } + fs::write( + project.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.1f1", + ) + .unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let host = PluginHost::default(); + host.initialize(config.path()).unwrap(); + host.register_editor_adapter(Box::new(StubUnityAdapter)) + .unwrap(); + host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) + .unwrap(); + assert!(host + .list() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-unity-editor")); + host.start("agc-unity-editor").unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if host.list().unwrap().iter().any(|plugin| { + plugin.id == "agc-unity-editor" + && plugin.commands.len() == 1 + && plugin.capabilities.len() == 1 + }) { + break; + } + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(20)); + } + let plugin_pid = host.state.lock().unwrap().plugins["agc-unity-editor"] + .running + .as_ref() + .unwrap() + .child + .id(); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .unwrap(); + let response = host + .call( + "agc-unity-editor", + "unity.editor.execute".to_string(), + json!({"code":"return 2;"}), + ) + .unwrap(); + assert_eq!(response["status"], "completed"); + assert_eq!( + response["result"]["projectPath"], + project + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + let other = tempdir().unwrap(); + host.set_active_project(Some(other.path().to_string_lossy().into_owned())) + .unwrap(); + let response = host + .call( + "agc-unity-editor", + "unity.editor.execute".to_string(), + json!({"code":"return 3;"}), + ) + .unwrap(); + assert_eq!(response["status"], "completed"); + assert_eq!( + response["result"]["projectPath"], + other + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + host.set_active_project(None).unwrap(); + assert!(host + .list() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-unity-editor")); + assert_eq!( + host.state.lock().unwrap().plugins["agc-unity-editor"] + .running + .as_ref() + .unwrap() + .child + .id(), + plugin_pid + ); + let response = host + .call( + "agc-unity-editor", + "unity.editor.execute".to_string(), + json!({"code":"return 4;"}), + ) + .unwrap(); + assert_eq!(response["status"], "failed"); + assert_eq!(response["dispatched"], false); + host.stop("agc-unity-editor").unwrap(); + } impl EditorAdapter for StubCocosAdapter { fn id(&self) -> &'static str { @@ -2116,7 +2300,9 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p Err("stub adapter 不建立连接".to_string()) } - fn disconnect(&mut self) {} + fn disconnect(&mut self) { + self.disconnects.fetch_add(1, Ordering::SeqCst); + } fn translate_rpc(&self, _method: &str, params: Value) -> Result { Ok(params) @@ -2142,7 +2328,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let host = PluginHost::default(); crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state"); host.initialize(directory.path()).expect("initialize"); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .expect("register adapter"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); @@ -2194,7 +2380,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p host.initialize(directory.path()).expect("initialize"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .expect("register adapter"); let project = fs::canonicalize(directory.path()) .expect("canonical project") @@ -2238,26 +2424,98 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p } #[test] - fn cocos_plugin_is_hidden_and_cannot_start_for_non_cocos_project() { + fn cocos_plugin_stays_available_across_project_contexts() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); crate::builtin_plugins::initialize(directory.path()).expect("builtin state"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + let adapter = StubCocosAdapter::default(); + let disconnects = Arc::clone(&adapter.disconnects); + host.register_editor_adapter(Box::new(adapter)) .expect("register adapter"); host.set_plugin_workspace(workspace).expect("set workspace"); - let project = tempdir().expect("web project"); - host.set_active_project(Some(project.path().to_string_lossy().into_owned())) - .expect("set active project"); assert!(host .list() .expect("list plugins") .into_iter() - .all(|plugin| plugin.id != "agc-cocos-editor")); - assert!(host.start("agc-cocos-editor").is_err()); + .any(|plugin| plugin.id == "agc-cocos-editor")); + host.start("agc-cocos-editor") + .expect("start without a project"); + let deadline = Instant::now() + Duration::from_secs(15); + while host.read_panel("agc-cocos-editor", "cocos-editor").is_err() + || !host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .as_ref() + .unwrap() + .registrations + .lock() + .unwrap() + .subscriptions + .values() + .any(|event| event == "project.changed") + { + assert!(Instant::now() < deadline, "Cocos panel was not registered"); + thread::sleep(Duration::from_millis(25)); + } + let plugin_pid = host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .as_ref() + .unwrap() + .child + .id(); + let project = tempdir().expect("web project"); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .expect("set active project"); + assert_eq!(disconnects.load(Ordering::SeqCst), 1); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .expect("keep the same active project"); + assert_eq!(disconnects.load(Ordering::SeqCst), 1); + let response = host + .call( + "agc-cocos-editor", + "cocos.editor.execute".to_string(), + json!({"code":"return 1;"}), + ) + .expect("RPC reaches the adapter without a project type gate"); + assert_eq!(response["status"], "completed"); + assert_eq!( + response["response"]["params"]["projectPath"], + project + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + host.set_active_project(None).unwrap(); + assert_eq!(disconnects.load(Ordering::SeqCst), 2); + assert!(host + .list_extensions() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-cocos-editor")); + host.read_panel("agc-cocos-editor", "cocos-editor") + .expect("panel remains available"); + assert_eq!( + host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .as_ref() + .unwrap() + .child + .id(), + plugin_pid + ); + assert!(host + .call( + "agc-cocos-editor", + "cocos.editor.execute".to_string(), + json!({"code":"return 1;"}), + ) + .is_err()); + host.stop("agc-cocos-editor").unwrap(); } #[test] @@ -2300,7 +2558,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p assert!(host.state.lock().unwrap().plugins["agc-cocos-editor"] .running .is_none()); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .unwrap(); assert!(host .list() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 9432589c4..d81ed7686 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -36,6 +36,7 @@ pub(crate) fn list_local_project_files_at( * 前端资源树加载一堆永远用不上的条目。 */ let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some(); + let is_unity_project = discover_local_unity_project_root(root)?.is_some(); let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { @@ -65,6 +66,15 @@ pub(crate) fn list_local_project_files_at( .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) .unwrap_or(0); if file_type.is_dir() { + if is_unity_project + && !relative_path.contains('/') + && matches!( + relative_path.to_ascii_lowercase().as_str(), + "library" | "temp" | "obj" | "logs" | "usersettings" + ) + { + continue; + } if skip_engine_generated_directories && is_engine_generated_root_directory(&relative_path) { 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 4f8b0c793..3fd7a2ff2 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 @@ -181,6 +181,33 @@ fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), Strin Ok(()) } +/// Unity 工程身份来自根目录普通文件和目录;禁止借助链接跳到其它工程。 +pub(crate) fn discover_local_unity_project_root(root: &Path) -> Result, String> { + if root.as_os_str().is_empty() || !root.is_absolute() || project_path_has_control_chars(root) { + return Err("Unity 项目目录必须是无控制字符的绝对路径".to_string()); + } + for (path, directory) in [ + (root.to_path_buf(), true), + (root.join("Assets"), true), + (root.join("Packages"), true), + (root.join("ProjectSettings"), true), + (root.join("ProjectSettings/ProjectVersion.txt"), false), + ] { + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("读取 Unity 项目结构失败:{error}")), + }; + if godot_metadata_is_link(&metadata) + || (directory && !metadata.is_dir()) + || (!directory && !metadata.is_file()) + { + return Ok(None); + } + } + Ok(Some(".".to_string())) +} + pub(crate) fn discover_local_cocos_project_root( workspace_root: &Path, ) -> Result, String> { @@ -763,6 +790,49 @@ pub(crate) fn import_local_cocos_project_at( }) } +pub(crate) fn import_local_unity_project_at( + root: &Path, + project_id: &str, + name: &str, +) -> Result { + discover_local_unity_project_root(root)?.ok_or_else(|| + "所选目录不是有效的 Unity 项目(需要 Assets、Packages 和 ProjectSettings/ProjectVersion.txt)".to_string())?; + if project_id.is_empty() { + return Err("项目 ID 不能为空".to_string()); + } + let name = normalize_game_creation_project_name(name)?; + prepare_game_creator_project_root_for_read(root, true, "Unity 工作区目录")?; + let manifest_path = root.join(".agent/manifest.json"); + if manifest_storage_exists(&manifest_path)? { + return Ok(InitLocalProjectResult { + project_path: root.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + manifest: read_manifest(&manifest_path)?, + }); + } + if !root.join(".agent/agent.db").exists() { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.import", "projectId": project_id, + "name": name, "projectKind": "unity", "unityProjectRoot": ".", + }), + )?; + } + for relative in [".agent/logs", ".agent/runtime"] { + let path = root.join(relative); + ensure_game_creator_private_directory_tree(&path, "Unity 项目 Agent 目录")?; + prepare_game_creator_private_path_for_read(&path, true, "Unity 项目 Agent 目录")?; + } + let manifest = new_game_creation_app_manifest(project_id, name); + write_manifest(&manifest_path, &manifest)?; + Ok(InitLocalProjectResult { + project_path: root.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + manifest, + }) +} + pub(crate) fn record_preview_state( root: &Path, status: GameCreationAppPreviewStatus, @@ -1069,9 +1139,15 @@ pub(crate) fn validate_manifest_required_visual_asset( task_id: &str, ) -> Result<(), String> { let (expected_path, expected_kind) = match task_id { - "art-director" => ("assets/art-spec.png", "icon-spec"), - "design-foundation" => ("assets/ui-prototype.png", "ui-prototype"), - "art-asset-plan" => ("assets/art-spritesheet.png", "art-spritesheet"), + "art-director" => ("assets/art-spec.png", GameCreationAppAssetKind::IconSpec), + "design-foundation" => ( + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ), + "art-asset-plan" => ( + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ), _ => return Ok(()), }; let asset = manifest @@ -1150,7 +1226,10 @@ pub(crate) fn validate_manifest_required_visual_asset( let art_spec = manifest .assets .iter() - .find(|asset| asset.local_path == "assets/art-spec.png" && asset.kind == "icon-spec") + .find(|asset| { + asset.local_path == "assets/art-spec.png" + && asset.kind == GameCreationAppAssetKind::IconSpec + }) .ok_or_else(|| "缺少当前统一视觉规范图".to_string())?; let art_spec_project_id = art_spec .source @@ -1190,7 +1269,7 @@ pub(crate) fn validate_manifest_required_visual_asset( &art_spec.id, &format!("{:x}", Sha256::digest(&art_spec_bytes)), &art_spec.media_type, - &art_spec.kind, + art_spec.kind.as_str(), )?; external_editor_remote_reference_matches_local_source_at( root, @@ -2167,6 +2246,13 @@ pub(crate) fn read_manifest(path: &Path) -> Result Result>> = OnceLock::new(); + for (raw_kind, asset_id, local_path) in non_canonical_manifest_asset_kinds(payload) { + let logged = LOGGED_OFFENDERS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); + let first_seen = logged + .lock() + .map(|mut logged| { + logged.insert(format!( + "{}\u{1}{}\u{1}{asset_id}\u{1}{local_path}", + source_path.display(), + raw_kind, + )) + }) + .unwrap_or(true); + if !first_seen { + continue; + } + app_log!( + "manifest 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} assetId={asset_id} localPath={local_path} source={}({label})", + source_path.display() + ); + } +} + +/// 从 manifest 原始 JSON 的 `assets[].kind` 里挑出不是 canonical 值的条目, +/// 返回 `(原值, 资产 ID, 本地路径)`。JSON 结构不是预期形状时返回空表(读取本身会另行报错)。 +fn non_canonical_manifest_asset_kinds(payload: &str) -> Vec<(String, String, String)> { + let Ok(value) = serde_json::from_str::(payload) else { + return Vec::new(); + }; + let Some(assets) = value.get("assets").and_then(serde_json::Value::as_array) else { + return Vec::new(); + }; + let mut found = Vec::new(); + for asset in assets { + let Some(raw_kind) = asset.get("kind").and_then(serde_json::Value::as_str) else { + continue; + }; + // 判据直接来自唯一词汇表(`ALL` 含字面 `unknown`),不再另开一个"纯解析"入口: + // 严格解析入口只有 `parse_with_context`,这里只需要判真值、不需要再留痕。 + if GameCreationAppAssetKind::ALL + .iter() + .any(|kind| kind.as_str() == raw_kind) + { + continue; + } + found.push(( + raw_kind.to_string(), + asset + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + asset + .get("localPath") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + )); + } + found +} + /// manifest 的 `schemaVersion` 门:只接受当前唯一已知版本,未知版本一律失败关闭。 /// /// 为什么必须在**读侧**校验:manifest 是项目的持久化业务真相,而 [`write_manifest_locked`] 会把 diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index 0dab003fb..78ca10a5d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -19,7 +19,7 @@ fn project_with_two_assets(test_name: &str) -> (PathBuf, String) { let first = register_local_asset_entry( &root, "assets/hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "asset", GameCreationAppAssetSource { @@ -39,7 +39,7 @@ fn project_with_two_assets(test_name: &str) -> (PathBuf, String) { register_local_asset_entry( &root, "assets/theme.mp3", - "background-music", + GameCreationAppAssetKind::BackgroundMusic, "audio/mpeg", "asset", GameCreationAppAssetSource { @@ -561,7 +561,12 @@ fn asset_classification_audit_failure_is_reported_and_never_faked() { } /// 批量标签测试的公共脚手架:登记素材、读原始字节、数审计记录。 -fn register_batch_tag_asset(root: &Path, local_path: &str, kind: &str, media_type: &str) -> String { +fn register_batch_tag_asset( + root: &Path, + local_path: &str, + kind: GameCreationAppAssetKind, + media_type: &str, +) -> String { register_local_asset_entry( root, local_path, @@ -587,7 +592,7 @@ fn register_batch_tag_asset(root: &Path, local_path: &str, kind: &str, media_typ fn project_with_batch_tag_assets( test_name: &str, - specs: &[(&str, &str, &str)], + specs: &[(&str, GameCreationAppAssetKind, &str)], ) -> (PathBuf, Vec) { let root = classification_test_root(test_name); init_local_game_project_at(&root, "batch-tag-project", "批量标签测试") @@ -595,7 +600,7 @@ fn project_with_batch_tag_assets( let ids = specs .iter() .map(|(local_path, kind, media_type)| { - register_batch_tag_asset(&root, local_path, kind, media_type) + register_batch_tag_asset(&root, local_path, *kind, media_type) }) .collect::>(); (root, ids) @@ -647,9 +652,21 @@ fn batch_tags_append_keeps_existing_tags_and_categories() { let (root, ids) = project_with_batch_tag_assets( "batch-append", &[ - ("assets/hero.png", "character", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), - ("assets/untouched.png", "image", "image/png"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), + ( + "assets/untouched.png", + GameCreationAppAssetKind::Image, + "image/png", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -739,8 +756,16 @@ fn batch_tags_dedupe_asset_ids_and_tags() { let (root, ids) = project_with_batch_tag_assets( "batch-dedupe", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -781,8 +806,16 @@ fn batch_tags_repeat_is_a_noop_without_write_audit_or_revision() { let (root, ids) = project_with_batch_tag_assets( "batch-noop", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -833,8 +866,16 @@ fn batch_tags_mixed_noop_and_change_audits_only_changed_assets() { let (root, ids) = project_with_batch_tag_assets( "batch-mixed", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -907,8 +948,16 @@ fn batch_tags_missing_target_writes_nothing() { let (root, ids) = project_with_batch_tag_assets( "batch-missing", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -947,8 +996,16 @@ fn batch_tags_rejects_merged_tag_limit_and_single_tag_length() { let (root, ids) = project_with_batch_tag_assets( "batch-limits", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -1035,8 +1092,14 @@ fn batch_tags_rejects_merged_tag_limit_and_single_tag_length() { /// 空批次与空标签都不接受:空白 `assetId` 整批拒绝(不静默跳过),全空标签归一后拒绝。 #[test] fn batch_tags_rejects_empty_batch_and_empty_tags() { - let (root, ids) = - project_with_batch_tag_assets("batch-empty", &[("assets/hero.png", "image", "image/png")]); + let (root, ids) = project_with_batch_tag_assets( + "batch-empty", + &[( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + )], + ); let project_id = batch_tag_project_id(&root); let revision_before = batch_tag_revision(&root); let bytes_before = batch_tag_manifest_bytes(&root); @@ -1098,7 +1161,12 @@ fn batch_tags_accepts_two_hundred_assets_and_rejects_two_hundred_one() { .expect("initialize batch bound project"); let ids = (0..=ASSET_BATCH_TAG_MAX_ASSETS) .map(|index| { - register_batch_tag_asset(&root, &format!("assets/a{index}.png"), "image", "image/png") + register_batch_tag_asset( + &root, + &format!("assets/a{index}.png"), + GameCreationAppAssetKind::Image, + "image/png", + ) }) .collect::>(); let project_id = batch_tag_project_id(&root); @@ -1139,8 +1207,14 @@ fn batch_tags_accepts_two_hundred_assets_and_rejects_two_hundred_one() { /// 项目身份与 revision CAS 沿用单素材口径:身份不符 / 版本冲突都在写之前失败。 #[test] fn batch_tags_keeps_project_identity_and_revision_cas() { - let (root, ids) = - project_with_batch_tag_assets("batch-cas", &[("assets/hero.png", "image", "image/png")]); + let (root, ids) = project_with_batch_tag_assets( + "batch-cas", + &[( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + )], + ); let project_id = batch_tag_project_id(&root); let revision_before = batch_tag_revision(&root); let bytes_before = batch_tag_manifest_bytes(&root); @@ -1201,7 +1275,11 @@ fn batch_tags_input_rejects_unknown_fields() { fn batch_tags_command_requires_asset_register_permission() { let (root, ids) = project_with_batch_tag_assets( "batch-permission", - &[("assets/hero.png", "image", "image/png")], + &[( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + )], ); let project_id = batch_tag_project_id(&root); let revision_before = batch_tag_revision(&root); @@ -1234,8 +1312,16 @@ fn batch_tags_audit_failure_reports_written_batch() { let (root, ids) = project_with_batch_tag_assets( "batch-audit-failure", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index 43f967d42..206fcaf2e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -96,7 +96,7 @@ fn manifest_task_and_asset_mutations_are_serialized_across_the_full_transaction( crate::assets::register_local_asset_entry( &second_root, "assets/concurrent.png", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, "image/png", "test", GameCreationAppAssetSource { @@ -180,12 +180,12 @@ fn successful_first_playable_registration_creates_one_initial_version_with_asset let mut manifest = new_game_creation_app_manifest("project-first-playable", "首板项目"); manifest.assets.push(GameCreationAppAssetManifestEntry { id: "asset-player".to_string(), - kind: "character".to_string(), + kind: GameCreationAppAssetKind::Character, media_type: "image/png".to_string(), local_path: "assets/player.png".to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("character"), + category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Character), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs index 8d32d431a..88f18ef86 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -631,6 +631,7 @@ pub(crate) fn read_project_resource_graph_at( #[cfg(test)] mod tests { use super::*; + use shared_contracts::game_creation_app::game_creation_app_asset_category_for_raw_kind; fn resource( resource_id: &str, @@ -652,12 +653,12 @@ mod tests { ) -> GameCreationAppAssetManifestEntry { GameCreationAppAssetManifestEntry { id: id.to_string(), - kind: "test".to_string(), + kind: GameCreationAppAssetKind::Unknown, media_type: "image/png".to_string(), local_path: format!("assets/{id}.png"), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("test"), + category: game_creation_app_asset_category_for_raw_kind("test", "test.resource-kind"), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 145330f56..7cee0b18a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -183,6 +183,8 @@ pub(crate) struct DeriveLocalProjectResourceInput { pub(crate) source_path: Option, #[serde(default)] pub(crate) source_media_type: Option, + /// 源资源 subtype:只接受 manifest 的正式 `GameCreationAppAssetKind` 成员。 + /// 任务产物、附件、项目版本与 Agent 回执等非 manifest 来源不通过该字段传递。 #[serde(default)] pub(crate) source_subtype: Option, #[serde(default)] @@ -307,6 +309,7 @@ pub(crate) struct NormalizeLocalProjectRasterResourceInput { pub(crate) source_resource_id: String, pub(crate) source_path: String, pub(crate) source_media_type: String, + /// 源资源 subtype:只接受 manifest 的正式 `GameCreationAppAssetKind` 成员。 #[serde(default)] pub(crate) source_subtype: Option, pub(crate) producer_task_id: String, @@ -320,12 +323,48 @@ pub(crate) struct NormalizeLocalProjectRasterResourceResult { pub(crate) manifest: GameCreationAppManifest, } +/// 资源编辑源身份的词汇表。 +/// +/// `Manifest` 只承载 manifest 的正式 `GameCreationAppAssetKind` 成员;项目版本快照与 +/// Agent 回执文本没有 manifest kind,单独成变体,避免它们混进 kind 字段后被静默收口成 +/// `unknown`。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ResourceEditSourceKind { + Manifest(GameCreationAppAssetKind), + ProjectVersion, + AgentResultText, +} + +impl ResourceEditSourceKind { + /// 私有账本与外部接口上下文使用的稳定标签。 + fn as_str(self) -> &'static str { + match self { + Self::Manifest(kind) => kind.as_str(), + Self::ProjectVersion => "project-version", + Self::AgentResultText => "agent-result-derivative", + } + } + + /// 派生资源写回 manifest 时使用的正式 kind;非 manifest 谱系没有 kind。 + fn manifest_kind(self) -> GameCreationAppAssetKind { + match self { + Self::Manifest(kind) => kind, + // Agent 回执派生物是 markdown 文本,与 `Text => Document` 同口径:写盘只写正式成员, + // 不能主动写 `Unknown`(那会把"派生物是文档"的事实换成"认不出的资源")。 + Self::AgentResultText => GameCreationAppAssetKind::Document, + // 版本编辑不走 manifest 资产写回通道(版本有独立提交路径),此分支不可达; + // 真走到这里也只表示"没有 manifest kind"。 + Self::ProjectVersion => GameCreationAppAssetKind::Unknown, + } + } +} + #[derive(Clone, Debug)] struct ResourceEditSourceSnapshot { canonical_resource_id: String, source_path: Option, media_type: String, - asset_kind: String, + asset_kind: ResourceEditSourceKind, source_sha256: String, bytes: Option>, generation_mode: LocalProjectResourceGenerationMode, @@ -1096,15 +1135,14 @@ fn source_asset_canonical_resource_id(asset: &GameCreationAppAssetManifestEntry) format!("local-asset:{}", asset.id) } -fn resource_edit_audio_kind(asset_kind: &str, path: &str) -> LocalProjectResourceEditKind { - let haystack = format!( - "{} {}", - asset_kind.to_ascii_lowercase(), - path.to_ascii_lowercase() - ); - if ["background", "bgm", "music", "theme"] - .iter() - .any(|marker| haystack.contains(marker)) +fn resource_edit_audio_kind( + asset_kind: ResourceEditSourceKind, + path: &str, +) -> LocalProjectResourceEditKind { + if asset_kind == ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::BackgroundMusic) + || ["background", "bgm", "music", "theme"] + .iter() + .any(|marker| path.to_ascii_lowercase().contains(marker)) { LocalProjectResourceEditKind::BackgroundMusic } else { @@ -1180,20 +1218,28 @@ fn infer_resource_edit_source_media_type( Some(media_type.to_string()) } -fn infer_resource_edit_source_asset_kind(edit_kind: &LocalProjectResourceEditKind) -> String { - match edit_kind { +fn infer_resource_edit_source_asset_kind( + edit_kind: &LocalProjectResourceEditKind, +) -> ResourceEditSourceKind { + let kind = match edit_kind { LocalProjectResourceEditKind::ImageReference - | LocalProjectResourceEditKind::BackgroundRemoval => "art-image", - LocalProjectResourceEditKind::Svg => "svg", - LocalProjectResourceEditKind::CharacterAnimation => "character-animation", - LocalProjectResourceEditKind::Video => "video", - LocalProjectResourceEditKind::SoundEffect => "sound-effect", - LocalProjectResourceEditKind::BackgroundMusic => "background-music", - LocalProjectResourceEditKind::Text => "text", - LocalProjectResourceEditKind::AgentResult => "agent-result-derivative", - LocalProjectResourceEditKind::Version => "project-version", - } - .to_string() + | LocalProjectResourceEditKind::BackgroundRemoval + | LocalProjectResourceEditKind::Svg => GameCreationAppAssetKind::Image, + LocalProjectResourceEditKind::CharacterAnimation => { + GameCreationAppAssetKind::CharacterAnimation + } + LocalProjectResourceEditKind::Video => GameCreationAppAssetKind::Video, + LocalProjectResourceEditKind::SoundEffect => GameCreationAppAssetKind::SoundEffect, + LocalProjectResourceEditKind::BackgroundMusic => GameCreationAppAssetKind::BackgroundMusic, + LocalProjectResourceEditKind::Text => GameCreationAppAssetKind::Document, + LocalProjectResourceEditKind::AgentResult => { + return ResourceEditSourceKind::AgentResultText; + } + LocalProjectResourceEditKind::Version => { + return ResourceEditSourceKind::ProjectVersion; + } + }; + ResourceEditSourceKind::Manifest(kind) } fn resolve_agent_result_source( @@ -1239,7 +1285,7 @@ fn resolve_resource_edit_source( canonical_resource_id: format!("version:{version_id}"), source_path: None, media_type: "application/vnd.genarrative.project-version+json".to_string(), - asset_kind: "project-version".to_string(), + asset_kind: ResourceEditSourceKind::ProjectVersion, source_sha256: sha256_hex( &serde_json::to_vec(&version) .map_err(|error| format!("序列化源项目版本失败:{error}"))?, @@ -1264,7 +1310,7 @@ fn resolve_resource_edit_source( canonical_resource_id, source_path: None, media_type: "text/markdown".to_string(), - asset_kind: "agent-result-derivative".to_string(), + asset_kind: ResourceEditSourceKind::AgentResultText, source_sha256: sha256_hex(content.as_bytes()), bytes: None, generation_mode: input.generation_mode, @@ -1358,9 +1404,18 @@ fn resolve_resource_edit_source( .unwrap_or_default(); let asset_kind = source_asset .as_ref() - .map(|asset| asset.kind.clone()) - .or_else(|| input.source_subtype.clone()) - .unwrap_or_else(|| "asset".to_string()); + .map(|asset| ResourceEditSourceKind::Manifest(asset.kind)) + .or_else(|| { + input.source_subtype.as_deref().map(|value| { + ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::parse_with_context( + value, + "resource-edit.source-subtype", + )) + }) + }) + .unwrap_or(ResourceEditSourceKind::Manifest( + GameCreationAppAssetKind::Unknown, + )); let canonical_resource_id = source_asset .as_ref() .map(source_asset_canonical_resource_id) @@ -1422,7 +1477,7 @@ fn resolve_resource_edit_source( if matches!( input.edit_kind, LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic - ) && resource_edit_audio_kind(&asset_kind, &path) != input.edit_kind + ) && resource_edit_audio_kind(asset_kind, &path) != input.edit_kind { return Err("音频资源的音效/BGM 编辑类型与源资源用途不一致".to_string()); } @@ -1877,7 +1932,7 @@ async fn confirm_resource_edit_source( "contentType": source.media_type, "contentLength": bytes.len(), "contentHash": source.source_sha256, - "assetKind": source.asset_kind, + "assetKind": source.asset_kind.as_str(), "accessPolicy": "private", })), ) @@ -1985,7 +2040,7 @@ async fn register_resource_edit_source_image( "width": decoded.width(), "height": decoded.height(), "sourceType": "uploaded", - "assetKind": source.asset_kind, + "assetKind": source.asset_kind.as_str(), "generationInputs": { "source": RESOURCE_EDIT_QUEUE_SOURCE, "localAssetId": local_asset_id, @@ -2101,7 +2156,7 @@ async fn ensure_resource_edit_source_reference( local_asset_id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.manifest_kind().as_str(), )?; let principal_key = external_editor_project_binding_key_sha256(&input.expected_project_id, principal)?; @@ -2327,7 +2382,7 @@ fn build_resource_edit_audio_prompt( .and_then(|asset| asset.source.prompt.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()); - let mut source_context = format!("名称 {source_name},用途 {}", source.asset_kind); + let mut source_context = format!("名称 {source_name},用途 {}", source.asset_kind.as_str()); if let Some(original_prompt) = original_prompt { source_context.push_str(",原始描述 "); source_context.push_str(original_prompt); @@ -2386,7 +2441,7 @@ fn resource_edit_remote_request( .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, "sourceResourceId": source_reference .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, - "assetKind": source.asset_kind, + "assetKind": source.asset_kind.as_str(), "assetLabel": asset_name, "backgroundMode": background_mode, "generationInputs": generation_inputs, @@ -3925,20 +3980,41 @@ pub(crate) fn normalize_local_project_raster_resource_at( .map_err(|error| format!("序列化源图片身份失败:{error}"))?; let identity_hash = sha256_hex(&identity_material); let asset_id = format!("normalized-{}", &identity_hash[..24]); - let source_subtype = input + // 该接口只把栅格源登记成 manifest 图片资产:源 subtype 只接受**图片族**成员。 + // 未声明/空白时按没有类型信息处理为 `image`;认不出或传入非图片族成员 + // (音频 / 视频 / 文档 / 字体 / 代码…)直接失败,避免静默掩盖调用方错误。 + let source_kind = match input .source_subtype .as_deref() .map(str::trim) - .filter(|value| !value.is_empty() && *value != "task-artifact") - .unwrap_or("art-image"); + .filter(|value| !value.is_empty()) + { + None => GameCreationAppAssetKind::Image, + Some(value) => { + let kind = + GameCreationAppAssetKind::parse_with_context(value, "resource-edit.normalize"); + if kind == GameCreationAppAssetKind::Unknown { + return Err(format!( + "sourceSubtype 必须是图片族 kind,无法识别:{value}" + )); + } + if !kind.is_visual() { + return Err(format!( + "sourceSubtype 必须是图片族 kind,不能使用:{}", + kind.as_str() + )); + } + kind + } + }; let asset = GameCreationAppAssetManifestEntry { id: asset_id.clone(), - kind: source_subtype.to_string(), + kind: source_kind, media_type: verified_media_type, local_path: source_path, image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind(source_subtype), + category: game_creation_app_asset_category_for_kind(source_kind), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -4179,11 +4255,11 @@ fn commit_resource_edit_asset_internal( .transpose()?; let image_sequence_duration_ms = ledger.remote_sequence_duration_ms; let asset_kind = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation { - "character-animation".to_string() + GameCreationAppAssetKind::CharacterAnimation } else { - source.asset_kind.clone() + source.asset_kind.manifest_kind() }; - let category = game_creation_app_asset_category_for_kind(&asset_kind); + let category = game_creation_app_asset_category_for_kind(asset_kind); let asset = GameCreationAppAssetManifestEntry { id: asset_id.clone(), kind: asset_kind, @@ -4583,7 +4659,7 @@ fn write_resource_edit_result_binding( &asset.id, &sha256_hex(&bytes), &asset.media_type, - &asset.kind, + asset.kind.as_str(), )?; let binding = new_external_editor_resource_binding( &input.expected_project_id, @@ -5292,8 +5368,14 @@ pub(crate) async fn resume_local_project_resource_edit_at( let source_subtype = ledger .source_asset_kind .clone() - .or_else(|| source_asset.as_ref().map(|asset| asset.kind.clone())) - .or_else(|| Some(infer_resource_edit_source_asset_kind(&ledger.edit_kind))); + .or_else(|| source_asset.as_ref().map(|asset| asset.kind.to_string())) + .or_else(|| { + Some( + infer_resource_edit_source_asset_kind(&ledger.edit_kind) + .as_str() + .to_string(), + ) + }); derive_local_project_resource_at(DeriveLocalProjectResourceInput { project_path: input.project_path, expected_project_id: input.expected_project_id, @@ -5400,7 +5482,7 @@ pub(crate) async fn derive_local_project_resource_at( source_asset_id: source.source_asset.as_ref().map(|asset| asset.id.clone()), source_path: source.source_path.clone(), source_media_type: Some(source.media_type.clone()), - source_asset_kind: Some(source.asset_kind.clone()), + source_asset_kind: Some(source.asset_kind.as_str().to_string()), producer_task_id: input.producer_task_id.clone().or_else(|| { source .source_asset @@ -5683,7 +5765,7 @@ mod tests { "details": { "provider": "editor-image-edit", "message": "当前素材类型不支持图片快速编辑", - "assetKind": "art-spritesheet", + "assetKind": "icon-spritesheet", "mediaType": "image", }, } @@ -5692,7 +5774,7 @@ mod tests { .as_str(), ), Some( - "当前素材类型不支持图片快速编辑|provider editor-image-edit|素材类型 art-spritesheet|媒体类型 image" + "当前素材类型不支持图片快速编辑|provider editor-image-edit|素材类型 icon-spritesheet|媒体类型 image" .to_string() ), "details.message 必须优先于通用 error.message,并附上 provider 与类型上下文" @@ -6126,7 +6208,7 @@ mod tests { source_asset_id: source.source_asset.as_ref().map(|asset| asset.id.clone()), source_path: source.source_path.clone(), source_media_type: Some(source.media_type.clone()), - source_asset_kind: Some(source.asset_kind.clone()), + source_asset_kind: Some(source.asset_kind.as_str().to_string()), producer_task_id: input.producer_task_id.clone().or_else(|| { source .source_asset @@ -6379,6 +6461,25 @@ mod tests { ); } + /// 派生资源写回 manifest 的 kind 必须是正式成员:Agent 回执派生物是 markdown 文本, + /// 与 `Text => Document` 同口径,不许主动写 `Unknown`。 + #[test] + fn derived_asset_manifest_kind_is_never_unknown_for_text_derivatives() { + assert_eq!( + ResourceEditSourceKind::AgentResultText.manifest_kind(), + GameCreationAppAssetKind::Document + ); + assert_eq!( + ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video).manifest_kind(), + GameCreationAppAssetKind::Video + ); + // 版本谱系没有 manifest kind,且不走资产写回通道(版本有独立提交路径)。 + assert_eq!( + ResourceEditSourceKind::ProjectVersion.manifest_kind(), + GameCreationAppAssetKind::Unknown + ); + } + #[test] fn local_media_upload_ticket_uses_legal_private_editor_namespace() { let directory = tempfile::tempdir().expect("create resource editor fixture"); @@ -6393,7 +6494,7 @@ mod tests { canonical_resource_id: "local-asset:video-1".to_string(), source_path: Some("assets/source-video.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(vec![1, 2, 3]), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -6614,7 +6715,7 @@ mod tests { &uploaded.id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.as_str(), ) .expect("owner A source identity"); let (a_resource_id, a_width, a_height) = @@ -6761,7 +6862,7 @@ mod tests { first_request.source_asset_id = Some(source_asset.id.clone()); first_request.source_path = Some(source_asset.local_path.clone()); first_request.source_media_type = Some(source_asset.media_type.clone()); - first_request.source_subtype = Some(source_asset.kind.clone()); + first_request.source_subtype = Some(source_asset.kind.to_string()); let mut second_request = first_request.clone(); second_request.operation_id = Uuid::new_v4().to_string(); second_request.idempotency_key = Uuid::new_v4().to_string(); @@ -6854,7 +6955,7 @@ mod tests { &source_asset.id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.as_str(), ) .expect("concurrent source identity"); let binding_key = external_editor_resource_binding_key_sha256( @@ -6908,7 +7009,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source(root, &manifest, &request) .expect("resolve character source"); let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); @@ -6942,7 +7043,7 @@ mod tests { &source_asset.id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.as_str(), ) .expect("character source identity"); write_external_editor_resource_binding_at( @@ -7032,7 +7133,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source(root, &manifest, &request).expect("resolve switch source"); let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); @@ -7203,7 +7304,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/accepted-switch-video.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: request.generation_mode, @@ -7261,7 +7362,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/video-one.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -7401,7 +7502,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/legacy-video.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -7574,7 +7675,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/identity-switch.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: None, generation_mode: request.generation_mode, @@ -7642,7 +7743,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/resource-identity-owner.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: None, generation_mode: request.generation_mode, @@ -7749,7 +7850,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/resource-owner-lease.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: None, generation_mode: request.generation_mode, @@ -7885,7 +7986,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source(root, &manifest, &request) .expect("resolve unowned source"); let prompt = normalize_resource_edit_prompt(&request.edit_kind, &request.prompt) @@ -7947,7 +8048,7 @@ mod tests { canonical_resource_id: "local-asset:video-submission-status".to_string(), source_path: Some("assets/video-submission-status.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8074,7 +8175,7 @@ mod tests { canonical_resource_id: "local-asset:video-accepted-response".to_string(), source_path: Some("assets/video-accepted-response.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8240,7 +8341,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/video-terminal-failure.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8392,7 +8493,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/archive-owner-video.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: request.generation_mode, @@ -8452,7 +8553,7 @@ mod tests { canonical_resource_id, source_path: Some("assets/pending-owner.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: None, generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8508,7 +8609,7 @@ mod tests { canonical_resource_id: local_request.source_resource_id.clone(), source_path: Some("assets/pending-local.md".to_string()), media_type: "text/markdown".to_string(), - asset_kind: "text".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Document), source_sha256: "b".repeat(64), bytes: Some(b"local".to_vec()), generation_mode: local_request.generation_mode, @@ -8599,7 +8700,7 @@ mod tests { canonical_resource_id: "local-asset:audio-1".to_string(), source_path: Some("audio/theme.ogg".to_string()), media_type: "audio/ogg".to_string(), - asset_kind: "background-music".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::BackgroundMusic), source_sha256: "a".repeat(64), bytes: Some(vec![1]), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8691,7 +8792,7 @@ mod tests { &background, &ResourceEditSourceSnapshot { media_type: "image/png".to_string(), - asset_kind: "art-image".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Image), source_path: Some("assets/source.png".to_string()), bytes: Some(resource_editor_test_png()), ..source.clone() @@ -8728,7 +8829,7 @@ mod tests { canonical_resource_id: "local-asset:media-1".to_string(), source_path: Some("assets/source.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -9186,7 +9287,7 @@ mod tests { source_resource_id: format!("task:{task_id}:assets/task-hero.png"), source_path: "assets/task-hero.png".to_string(), source_media_type: "image/png".to_string(), - source_subtype: Some("task-artifact".to_string()), + source_subtype: None, producer_task_id: task_id.clone(), }; @@ -9218,6 +9319,18 @@ mod tests { assert_eq!(replay.asset.id, first.asset.id); assert_eq!(replay.manifest.assets.len(), 1); + for invalid_subtype in ["background-music", "legacy-raster-kind"] { + let error = normalize_local_project_raster_resource_at( + NormalizeLocalProjectRasterResourceInput { + expected_project_revision: replay.committed_project_revision, + source_subtype: Some(invalid_subtype.to_string()), + ..request.clone() + }, + ) + .expect_err("non-image or unknown source subtype must fail"); + assert!(error.contains("sourceSubtype 必须是图片族 kind"), "{error}"); + } + let rejected = normalize_local_project_raster_resource_at(NormalizeLocalProjectRasterResourceInput { expected_project_revision: replay.committed_project_revision, @@ -9255,7 +9368,7 @@ mod tests { source_resource_id: format!("task:{task_id}:assets/task-hero.png"), source_path: "assets/task-hero.png".to_string(), source_media_type: "image/png".to_string(), - source_subtype: Some("task-artifact".to_string()), + source_subtype: None, producer_task_id: task_id, }; let first = normalize_local_project_raster_resource_at(request.clone()) @@ -9318,7 +9431,7 @@ mod tests { source_resource_id: format!("task:{task_id}:assets/task-hero.png"), source_path: "assets/task-hero.png".to_string(), source_media_type: "image/png".to_string(), - source_subtype: Some("task-artifact".to_string()), + source_subtype: None, producer_task_id: task_id, }; let first = normalize_local_project_raster_resource_at(request.clone()) @@ -9451,7 +9564,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source( root, &read_existing_manifest_for_project(root).expect("reread manifest"), @@ -9512,7 +9625,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source(root, &manifest, &request).expect("resolve commit source"); let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); @@ -9625,7 +9738,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let current_source = resolve_resource_edit_source(root, &manifest, &request) .expect("resolve current local source"); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs index aa731aeef..7fb65e509 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -197,7 +197,7 @@ fn background_removal_fixture_for_session( source_asset_id: Some(source_asset.id.clone()), source_path: Some(source_asset.local_path.clone()), source_media_type: Some(source_asset.media_type.clone()), - source_subtype: Some(source_asset.kind.clone()), + source_subtype: Some(source_asset.kind.to_string()), producer_task_id: source_asset.source.task_id.clone(), source_version_id: None, prompt: "去除背景".to_string(), @@ -230,7 +230,7 @@ fn background_removal_fixture_for_session( &source_asset.id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.as_str(), ) .expect("build source identity"); write_external_editor_resource_binding_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs index eab43394a..9da8b13de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs @@ -1,8 +1,7 @@ use super::*; use shared_contracts::game_creation_app::{ - canonical_game_creation_app_asset_kind, game_creation_app_asset_effective_category, - GameCreationAppAssetManifestEntry, + game_creation_app_asset_effective_category, GameCreationAppAssetManifestEntry, }; /// 版本级资源替换(**直接替换**口径)。 @@ -208,17 +207,16 @@ fn version_resource_size_spec_equal( /// 三项兼容性判据(后端是权威判据,前端只做呈现)。 /// /// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口); -/// - `subtypeEqual`:canonical `kind` 相等(别名表在 `shared-contracts`)——以上两项是硬门禁; +/// - `subtypeEqual`:`kind` 严格相等(非 canonical 原值一律收口成 `unknown`)——以上两项是硬门禁; /// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`],只作提示。 fn version_resource_compatibility( source: &GameCreationAppAssetManifestEntry, replacement: &GameCreationAppAssetManifestEntry, ) -> ProjectVersionResourceCompatibility { ProjectVersionResourceCompatibility { - category_equal: game_creation_app_asset_effective_category(&source.kind, source.category) - == game_creation_app_asset_effective_category(&replacement.kind, replacement.category), - subtype_equal: canonical_game_creation_app_asset_kind(&source.kind) - == canonical_game_creation_app_asset_kind(&replacement.kind), + category_equal: game_creation_app_asset_effective_category(source.kind, source.category) + == game_creation_app_asset_effective_category(replacement.kind, replacement.category), + subtype_equal: source.kind == replacement.kind, size_spec_equal: version_resource_size_spec_equal(source, replacement), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs index f81f9e747..010642de6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs @@ -22,6 +22,10 @@ pub(crate) struct ProjectSnapshotIndex { pub(crate) sync_revision: u64, pub(crate) synced_at_ms: u64, #[serde(default)] + pub(crate) project_name: Option, + #[serde(default)] + pub(crate) pending_files: Option, + #[serde(default)] pub(crate) files: BTreeMap, } @@ -35,6 +39,8 @@ pub(crate) fn empty_project_snapshot_index( user_id: user_id.to_string(), sync_revision: 0, synced_at_ms: 0, + project_name: None, + pending_files: None, files: BTreeMap::new(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs new file mode 100644 index 000000000..ece545897 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs @@ -0,0 +1,135 @@ +use super::*; + +/// 显式空登记保留在表中,避免返回首页后又从窗口的旧 URL 恢复项目。 +#[derive(Clone, Default)] +pub(crate) struct ProjectSnapshotWorkspaces { + windows: BTreeMap>, +} + +impl ProjectSnapshotWorkspaces { + pub(crate) fn project_for_window( + &self, + label: &str, + url_project: Option, + ) -> Option { + self.windows.get(label).cloned().unwrap_or(url_project) + } + + pub(crate) fn set_project( + &mut self, + label: &str, + project_path: Option, + ) -> Vec<(PathBuf, ProjectSnapshotSyncTrigger)> { + let previous = self + .windows + .insert(label.to_string(), project_path.clone()) + .flatten(); + let key = |path: &String| project_snapshot_sync_key(Path::new(path)); + if previous.as_ref().map(key) == project_path.as_ref().map(key) { + return Vec::new(); + } + let mut requests = Vec::new(); + if let Some(previous) = previous { + requests.push(( + PathBuf::from(previous), + ProjectSnapshotSyncTrigger::ProjectClose, + )); + } + if let Some(project_path) = project_path { + requests.push(( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::ProjectOpen, + )); + } + requests + } +} + +static PROJECT_SNAPSHOT_WORKSPACES: OnceLock> = OnceLock::new(); + +fn project_snapshot_workspaces() -> &'static Mutex { + PROJECT_SNAPSHOT_WORKSPACES.get_or_init(|| Mutex::new(ProjectSnapshotWorkspaces::default())) +} + +/// 窗口身份由 Tauri 注入,前端只能登记自身已经打开的普通项目目录。 +#[tauri::command] +pub(crate) fn set_active_project_snapshot_workspace( + window: tauri::Window, + project_path: Option, +) -> Result<(), String> { + let project_path = project_path + .map(|path| { + let root = resolve_project_snapshot_root(&path)?; + let manifest = read_existing_manifest_for_project(&root)?; + validate_project_snapshot_project_id(manifest.project_id.trim())?; + Ok::<_, String>(root.to_string_lossy().into_owned()) + }) + .transpose() + .inspect_err(|_| { + app_log!( + "project_snapshot.workspace.registration.failed window={} reason=invalid-project", + window.label() + ); + })?; + let requests = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .set_project(window.label(), project_path); + for (root, trigger) in requests { + request_project_snapshot_sync(root, trigger); + } + Ok(()) +} + +pub(crate) fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { + let registry = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + // WebView URL 读取可能回到主线程,不能持登记锁等待它,否则会与关窗/登记互锁。 + let mut paths = BTreeMap::new(); + for window in app.webview_windows().into_values() { + let url_project = window + .url() + .ok() + .and_then(|url| project_snapshot_project_path_from_url(&url)); + if let Some(path) = registry.project_for_window(window.label(), url_project) { + paths.insert(project_snapshot_sync_key(Path::new(&path)), path); + } + } + paths.into_values().collect() +} + +pub(crate) fn handle_project_snapshot_window_event( + window: &tauri::Window, + event: &tauri::WindowEvent, +) { + if matches!(event, tauri::WindowEvent::Destroyed) { + project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .windows + .remove(window.label()); + return; + } + if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + return; + } + let url_project = window + .app_handle() + .get_webview_window(window.label()) + .and_then(|webview| webview.url().ok()) + .and_then(|url| project_snapshot_project_path_from_url(&url)); + let mut registry = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let project_path = registry.project_for_window(window.label(), url_project); + registry.set_project(window.label(), None); + drop(registry); + if let Some(project_path) = project_path { + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::ProjectClose, + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs index bcdf2ce21..503682525 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -11,6 +11,7 @@ use std::time::Instant; mod diff; mod index; +mod lifecycle; mod scan; mod transport; @@ -19,6 +20,7 @@ mod tests; pub(crate) use diff::*; pub(crate) use index::*; +pub(crate) use lifecycle::*; pub(crate) use scan::*; pub(crate) use transport::*; @@ -50,6 +52,7 @@ const PROJECT_SNAPSHOT_DISABLED_ENV: &str = "GENARRATIVE_AGC_PROJECT_SNAPSHOT_DI #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ProjectSnapshotSyncTrigger { + ProjectOpen, Periodic, ProjectClose, Manual, @@ -58,6 +61,7 @@ pub(crate) enum ProjectSnapshotSyncTrigger { impl ProjectSnapshotSyncTrigger { fn as_str(self) -> &'static str { match self { + Self::ProjectOpen => "project-open", Self::Periodic => "periodic", Self::ProjectClose => "project-close", Self::Manual => "manual", @@ -215,6 +219,19 @@ pub(crate) fn wait_for_project_snapshot_syncs(timeout: Duration) -> bool { } /// 把项目同步请求交给后台线程:窗口关闭与应用退出路径都不能被网络等待阻塞。 +fn spawn_project_snapshot_sync_task( + run: impl FnOnce() + Send + 'static, +) -> std::io::Result> { + // 退出等待必须连已排队但尚未开始执行的线程一起计入。 + let scheduled = ProjectSnapshotInFlightGuard::begin(); + std::thread::Builder::new() + .name("agc-project-snapshot".to_string()) + .spawn(move || { + let _scheduled = scheduled; + run(); + }) +} + pub(crate) fn request_project_snapshot_sync( project_root: PathBuf, trigger: ProjectSnapshotSyncTrigger, @@ -223,22 +240,22 @@ pub(crate) fn request_project_snapshot_sync( return; } let key = project_snapshot_sync_key(&project_root); - let spawn = std::thread::Builder::new() - .name("agc-project-snapshot".to_string()) - .spawn(move || { - let run = || sync_project_snapshot_blocking(&project_root, trigger); - let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { - try_run_project_snapshot_sync(&key, run) - } else { - Some(run_project_snapshot_sync(&key, run)) - }; - if let Some(Err(error)) = outcome { - app_log!( - "project_snapshot.sync.failed trigger={}: {error}", - trigger.as_str() - ); - } - }); + let spawn = spawn_project_snapshot_sync_task(move || { + let run = || sync_project_snapshot_blocking(&project_root, trigger); + let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { + try_run_project_snapshot_sync(&key, run) + } else { + Some(run_project_snapshot_sync(&key, run)) + }; + if let Some(Err(error)) = outcome { + let error = error.replace(project_root.to_string_lossy().as_ref(), ""); + app_log!( + "project_snapshot.sync.failed projectKey={:016x} trigger={}: {error}", + fnv1a64(key.as_bytes()), + trigger.as_str() + ); + } + }); if let Err(error) = spawn { app_log!("project_snapshot.sync.spawn.failed: {error}"); } @@ -296,7 +313,9 @@ async fn sync_project_snapshot_async( } let diff = compute_project_snapshot_diff(&scan, &previous.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)?; - if !diff.has_changes() { + let project_name = (!manifest.name.trim().is_empty()).then(|| manifest.name.trim().to_string()); + let pending_files = project_snapshot_pending_file_count(&diff, &[]); + if !project_snapshot_manifest_needs_sync(&previous, &diff, &project_name, pending_files) { return Ok(ProjectSnapshotSyncReport { project_id, trigger: trigger.as_str().to_string(), @@ -316,6 +335,7 @@ async fn sync_project_snapshot_async( } let upload = upload_project_snapshot_diff(&session, &project_id, &diff).await; + let pending_files = project_snapshot_pending_file_count(&diff, &upload.failures); let synced_files = build_project_snapshot_synced_files(&diff, &upload.uploaded_paths); let next_revision = previous.sync_revision.saturating_add(1); let synced_at_ms = u64::try_from(unix_millis()).unwrap_or(u64::MAX); @@ -325,6 +345,8 @@ async fn sync_project_snapshot_async( project_id: project_id.clone(), sync_revision: next_revision, synced_at_ms, + project_name: project_name.clone(), + pending_files: Some(pending_files), files: synced_files .iter() .map(|(relative_path, file)| { @@ -346,12 +368,14 @@ async fn sync_project_snapshot_async( user_id: session.user_id.clone(), sync_revision: next_revision, synced_at_ms, + project_name, + pending_files: Some(pending_files), files: synced_files, })?; - let status = if upload.failures.is_empty() { + let status = if pending_files == 0 { "synced" - } else if upload.uploaded_paths.is_empty() { + } else if !upload.failures.is_empty() && upload.uploaded_paths.is_empty() { "failed" } else { "partial" @@ -381,7 +405,8 @@ async fn sync_project_snapshot_async( synced_at_ms, }; app_log!( - "project_snapshot.sync.completed trigger={} status={} revision={} uploaded={} skippedRemote={} deleted={} deferred={} failed={}", + "project_snapshot.sync.completed projectId={} trigger={} status={} revision={} uploaded={} skippedRemote={} deleted={} deferred={} failed={} pending={}", + report.project_id, report.trigger, report.status, report.sync_revision, @@ -389,7 +414,8 @@ async fn sync_project_snapshot_async( report.remote_skipped_files, report.deleted_files, report.deferred_files, - report.failed_files.len() + report.failed_files.len(), + pending_files ); Ok(report) } @@ -405,53 +431,39 @@ fn failure_views(skipped: &[ProjectSnapshotSkippedPath]) -> Vec u32 { + let paths = diff + .skipped + .iter() + .chain(&diff.deferred) + .chain(&diff.pending) + .map(|entry| entry.relative_path.as_str()) + .chain(failures.iter().map(|entry| entry.relative_path.as_str())) + .collect::>(); + u32::try_from(paths.len()).unwrap_or(u32::MAX) +} + +fn project_snapshot_manifest_needs_sync( + previous: &ProjectSnapshotIndex, + diff: &ProjectSnapshotDiff, + project_name: &Option, + pending_files: u32, +) -> bool { + diff.has_changes() + || previous.project_name != *project_name + || previous.pending_files != Some(pending_files) +} + +/// 独立 supervisor-chat 窗口尚未登记时,从其 URL 读取项目路径。 pub(crate) fn project_snapshot_project_path_from_url(url: &url::Url) -> Option { url.query_pairs() .find_map(|(key, value)| (key == "projectPath").then(|| value.into_owned())) .filter(|value| !value.trim().is_empty()) } -fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { - let mut paths = BTreeSet::new(); - for window in app.webview_windows().into_values() { - let Ok(url) = window.url() else { - continue; - }; - if let Some(project_path) = project_snapshot_project_path_from_url(&url) { - paths.insert(project_path); - } - } - paths.into_iter().collect() -} - -/// 工作区窗口关闭即视为项目关闭:立刻补一次同步。 -pub(crate) fn handle_project_snapshot_window_event( - window: &tauri::Window, - event: &tauri::WindowEvent, -) { - if !project_snapshot_sync_enabled() { - return; - } - if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { - return; - } - // `tauri::Window` 不暴露 WebView 地址,按标签取回对应的 WebView 窗口再读 URL。 - let Some(webview) = window.app_handle().get_webview_window(window.label()) else { - return; - }; - let Ok(url) = webview.url() else { - return; - }; - let Some(project_path) = project_snapshot_project_path_from_url(&url) else { - return; - }; - request_project_snapshot_sync( - PathBuf::from(project_path), - ProjectSnapshotSyncTrigger::ProjectClose, - ); -} - /// 应用退出前等待在途同步收尾。 /// /// 退出时刻窗口已销毁,按窗口重新枚举项目只会得到空集,因此这里不重复发起同步: @@ -465,7 +477,7 @@ pub(crate) fn wait_for_project_snapshot_syncs_on_exit() { } } -/// 周期定时器:只为当前仍打开的项目触发,进程内项目集合由窗口 URL 决定。 +/// 周期定时器:只为当前窗口登记的项目触发,不扫描其它本地项目。 pub(crate) fn spawn_project_snapshot_scheduler(app: tauri::AppHandle) { if !project_snapshot_sync_enabled() { app_log!("project_snapshot.scheduler.disabled"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs index 2163c4b24..964e10a38 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs @@ -52,6 +52,9 @@ pub(crate) fn scan_project_snapshot_files( let Ok(relative_path) = relative_project_path(root, &path) else { continue; }; + if should_skip_project_snapshot_path(&relative_path) { + continue; + } let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, Err(error) => { @@ -62,14 +65,8 @@ pub(crate) fn scan_project_snapshot_files( continue; } }; - if should_skip_project_snapshot_path(&relative_path) { - continue; - } if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - result.skipped.push(ProjectSnapshotSkippedPath { - relative_path, - reason: "符号链接或重解析点不参与项目快照".to_string(), - }); + // 与凭据和构建缓存一样属于明确排除范围,不计入工程缺失数量。 continue; } if metadata.is_dir() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs index ff57bdfae..a39b2b8b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs @@ -280,6 +280,28 @@ fn project_snapshot_periodic_trigger_yields_while_a_sync_is_in_flight() { assert!(try_run_project_snapshot_sync(key, || ()).is_some()); } +#[test] +fn project_snapshot_exit_wait_includes_scheduled_tasks_waiting_for_the_project_lock() { + let key = "snapshot-close-enqueue-exit-test"; + let lock = project_snapshot_project_lock(key); + let held = lock.lock().unwrap(); + let completed = Arc::new(AtomicUsize::new(0)); + let observed = completed.clone(); + let task = spawn_project_snapshot_sync_task(move || { + run_project_snapshot_sync(key, || { + observed.fetch_add(1, Ordering::SeqCst); + }); + }) + .expect("enqueue closing sync"); + // 线程尚未拿到项目锁时,退出也必须等待;不能等开始上传才计入。 + assert!(!wait_for_project_snapshot_syncs(Duration::from_millis(5))); + assert_eq!(completed.load(Ordering::SeqCst), 0); + drop(held); + task.join().expect("closing sync completed"); + assert_eq!(completed.load(Ordering::SeqCst), 1); + assert!(wait_for_project_snapshot_syncs(Duration::from_secs(2))); +} + fn read_http_request_with_body(stream: &mut TcpStream) -> String { stream .set_read_timeout(Some(Duration::from_secs(5))) @@ -518,7 +540,7 @@ fn project_snapshot_upload_stops_after_a_deterministic_authentication_failure() } #[test] -fn project_snapshot_project_path_is_read_from_window_urls_only() { +fn project_snapshot_independent_window_url_supplies_a_fallback_project() { let url = url::Url::parse("tauri://localhost/index.html?main&projectPath=C%3A%5Cgames%5Cdemo") .expect("parse fixture url"); assert_eq!( @@ -529,6 +551,148 @@ fn project_snapshot_project_path_is_read_from_window_urls_only() { assert_eq!(project_snapshot_project_path_from_url(&launcher), None); } +#[test] +fn project_snapshot_workspace_lifecycle_tracks_the_single_client_window() { + let mut registry = ProjectSnapshotWorkspaces::default(); + let first = "/projects/first".to_string(); + let second = "/projects/second".to_string(); + assert_eq!(registry.project_for_window("client", None), None); + assert_eq!( + registry.set_project("client", Some(first.clone())), + vec![( + PathBuf::from(&first), + ProjectSnapshotSyncTrigger::ProjectOpen + )] + ); + assert_eq!( + registry.project_for_window("client", None), + Some(first.clone()) + ); + assert!(registry + .set_project("client", Some(first.clone())) + .is_empty()); + assert_eq!( + registry.set_project("client", Some(second.clone())), + vec![ + ( + PathBuf::from(&first), + ProjectSnapshotSyncTrigger::ProjectClose + ), + ( + PathBuf::from(&second), + ProjectSnapshotSyncTrigger::ProjectOpen + ), + ] + ); + registry.set_project("other", Some(first.clone())); + assert_eq!( + registry.set_project("client", None), + vec![( + PathBuf::from(&second), + ProjectSnapshotSyncTrigger::ProjectClose + )] + ); + assert_eq!(registry.project_for_window("client", Some(second)), None); + assert_eq!(registry.project_for_window("other", None), Some(first)); + assert!(registry.set_project("client", None).is_empty()); +} + +#[test] +fn project_snapshot_manifest_metadata_changes_sync_without_content_changes() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/empty.txt", b""); + let initial = compute_project_snapshot_diff( + &scan_fixture(root.path()), + &BTreeMap::new(), + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .unwrap(); + assert_eq!(initial.uploads[0].size_bytes, 0); + let mut previous = empty_project_snapshot_index("project-1", "user-1"); + previous.files = initial.current; + let mut unchanged = compute_project_snapshot_diff( + &scan_fixture(root.path()), + &previous.files, + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .unwrap(); + assert!(!unchanged.has_changes()); + let name = Some("完整工程".to_string()); + assert!(project_snapshot_manifest_needs_sync( + &previous, &unchanged, &name, 0 + )); + previous.project_name = name.clone(); + previous.pending_files = Some(0); + assert!(!project_snapshot_manifest_needs_sync( + &previous, &unchanged, &name, 0 + )); + assert!(project_snapshot_manifest_needs_sync( + &previous, + &unchanged, + &Some("已改名".to_string()), + 0 + )); + + unchanged.skipped.push(ProjectSnapshotSkippedPath { + relative_path: "assets/large.bin".into(), + reason: "单文件超限".into(), + }); + let pending = project_snapshot_pending_file_count(&unchanged, &[]); + assert_eq!(pending, 1); + assert!(project_snapshot_manifest_needs_sync( + &previous, &unchanged, &name, pending + )); + previous.pending_files = Some(pending); + assert!(!project_snapshot_manifest_needs_sync( + &previous, &unchanged, &name, pending + )); + unchanged.skipped.clear(); + assert!(project_snapshot_manifest_needs_sync( + &previous, + &unchanged, + &name, + project_snapshot_pending_file_count(&unchanged, &[]) + )); +} + +#[test] +fn project_snapshot_pending_files_include_every_unsynced_path_once() { + let skipped = |path: &str| ProjectSnapshotSkippedPath { + relative_path: path.into(), + reason: "暂未同步".into(), + }; + let diff = ProjectSnapshotDiff { + skipped: vec![skipped("assets/large.bin")], + deferred: vec![skipped("assets/later.png")], + pending: vec![skipped("game/changing.js")], + ..Default::default() + }; + let failures = vec![ + ProjectSnapshotUploadFailure { + relative_path: "assets/failed.png".into(), + code: "transport-failed".into(), + detail: "失败".into(), + }, + ProjectSnapshotUploadFailure { + relative_path: "game/changing.js".into(), + code: "file-changed".into(), + detail: "变化".into(), + }, + ]; + assert_eq!(project_snapshot_pending_file_count(&diff, &failures), 4); +} + +#[test] +fn project_snapshot_legacy_index_keeps_completeness_unknown() { + let index: ProjectSnapshotIndex = serde_json::from_value(serde_json::json!({ + "schemaVersion": 1, "projectId": "project-1", "userId": "user-1", + "syncRevision": 1, "syncedAtMs": 1, "files": {} + })) + .unwrap(); + assert_eq!(index.project_name, None); + assert_eq!(index.pending_files, None); +} + /// 真实链路冒烟:客户端差异引擎 → 本地 api-server → 真实 OSS。 /// /// 默认忽略;需要显式提供目标项目与登录态: diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 71fd5bf4c..c3a15f007 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -23,6 +23,10 @@ pub(crate) use client::{ steer_external_agent_runner, wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, }; +pub(crate) use client::{ + call_external_unity_editor, disconnect_external_unity_editor, + mark_external_unity_editor_uncertain, +}; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index e27a07792..f0e924543 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -509,6 +509,7 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( pub(super) fn external_agent_runner_client_read_timeout(method: &str) -> Duration { match method { "runtime.compact" => EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT, + "unity.editor.rpc" => Duration::from_secs(80), _ => EXTERNAL_AGENT_RUNNER_IO_TIMEOUT, } } @@ -1648,6 +1649,7 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( }; let params = ExternalAgentRunnerRequestParams { root: Some(root.to_string()), + editor_rpc: None, agent: agent.map(str::to_string), session_id: None, run_id: run_id.map(str::to_string), @@ -1678,6 +1680,185 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( } } +pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Result { + let deadline = Instant::now() + Duration::from_secs(80); + static EXECUTION_UNCERTAIN: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?; + let uncertain_result = || serde_json::json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"runner-receipt-unconfirmed","message":"Unity 执行回执未确认,核对后退出全部 AGC 和 Runner 再重新打开"}}); + if method == "execute" && EXECUTION_UNCERTAIN.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(uncertain_result()); + } + if method == "execute" + && crate::editor_adapters::unity_uncertain_fence_path(&config_dir).exists() + { + return Ok(uncertain_result()); + } + let endpoint = if crate::editor_adapters::unity_execution_fence_path(&config_dir).exists() { + // 在途 fence 可能只是正常并发;由活着的 owner 区分 busy 与 unknown。 + // 此分支绝不自动重启 Runner,以免丢失未确认执行的进程内状态。 + match read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir)) + { + Ok(endpoint) => endpoint, + Err(_) if method == "execute" => return Ok(uncertain_result()), + Err(error) => return Err(error), + } + } else { + let _configure = match external_agent_runner_configure_lock().try_lock() { + Ok(guard) => guard, + Err(_) if method == "execute" => { + return Ok(crate::editor_adapters::unity_not_dispatched( + "Runner 正在配置,请等待当前操作完成", + )) + } + Err(_) => return Err("Runner 正在配置,请等待当前操作完成".to_string()), + }; + ensure_external_agent_runner(&config_dir)? + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining < Duration::from_secs(16) { + return if method == "execute" { + Ok(crate::editor_adapters::unity_not_dispatched( + "Unity 调用启动预算已耗尽,未派发执行", + )) + } else { + Err("Unity 调用启动预算已耗尽".to_string()) + }; + } + if let Some(params) = params.as_object_mut() { + if params.get("timeoutMs").is_some_and(|value| { + !value + .as_u64() + .is_some_and(|timeout| (1..=60_000).contains(&timeout)) + }) { + return if method == "execute" { + Ok(crate::editor_adapters::unity_not_dispatched( + "timeoutMs 必须在 1..=60000", + )) + } else { + Err("timeoutMs 必须在 1..=60000".to_string()) + }; + } + let requested = params + .get("timeoutMs") + .and_then(Value::as_u64) + .unwrap_or(60_000); + let bounded = requested.min(remaining.as_millis().saturating_sub(15_000) as u64); + params.insert("timeoutMs".to_string(), serde_json::json!(bounded)); + } + let request_id = random_identifier(b"agc-unity-editor-request")?; + let request_params = ExternalAgentRunnerRequestParams { + editor_rpc: Some( + serde_json::json!({"method":method,"params":params,"deadlineMs":unix_millis()+remaining.as_millis() as u64}), + ), + ..Default::default() + }; + // 只发送一次,传输失败不能重新派发 execute。 + let response = send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + &endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id.clone(), + "unity.editor.rpc", + request_params, + Duration::from_secs(2), + remaining.saturating_sub(Duration::from_secs(7)), + Duration::from_secs(2), + ); + match response { + Ok(mut value) => { + if method == "execute" { + if !crate::editor_adapters::unity_execute_receipt_is_valid(&value) { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + return Ok(uncertain_result()); + } + if value["status"] == "needs-reconciliation" { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + return Ok(value); + } + let Some(ack_required) = value.get("ackRequired").and_then(Value::as_bool) else { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir); + return Ok(uncertain_result()); + }; + if let Some(object) = value.as_object_mut() { + object.remove("ackRequired"); + } + if !ack_required { + return Ok(value); + } + if Instant::now() + Duration::from_secs(3) >= deadline { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + return Ok(uncertain_result()); + } + let acknowledgement = + send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + &endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + random_identifier(b"agc-unity-ack")?, + "unity.editor.ack", + ExternalAgentRunnerRequestParams { + editor_rpc: Some(serde_json::json!({"requestId":request_id})), + ..Default::default() + }, + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_millis(500), + ); + if !acknowledgement.is_ok_and(|response| response["acknowledged"] == true) { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir); + return Ok(uncertain_result()); + } + } + Ok(value) + } + Err(_) if method == "execute" => { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(uncertain_result()) + } + Err(error) => Err(error), + } +} + +pub(crate) fn disconnect_external_unity_editor() -> Result<(), String> { + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(()); + }; + let path = external_agent_runner_endpoint_path(&config_dir); + if !path.exists() { + return Ok(()); + } + let endpoint = read_external_agent_runner_endpoint(&path)?; + send_external_agent_runner_request( + &endpoint, + "unity.editor.rpc", + ExternalAgentRunnerRequestParams { + editor_rpc: Some(serde_json::json!({"method":"disconnect","params":{}})), + ..Default::default() + }, + ) + .map(|_| ()) +} + +pub(crate) fn mark_external_unity_editor_uncertain() -> Result<(), String> { + let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?; + // 先保存 GUI 与 Runner 共享的单向 fence;网络丢失也不能解锁。 + crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir)?; + let endpoint = + read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir))?; + send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + &endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + random_identifier(b"agc-unity-mark-uncertain")?, + "unity.editor.mark_uncertain", + ExternalAgentRunnerRequestParams::default(), + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_millis(500), + ) + .map(|_| ()) +} + pub(crate) fn compact_external_agent_runner_context( root: &Path, agent: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 3186fd863..62659b134 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -236,7 +236,7 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( } fn external_agent_runner_method_requires_current_gui_owner_claim(method: &str) -> bool { - method.starts_with("runtime.") + method.starts_with("runtime.") || method.starts_with("unity.editor.") } pub(super) fn external_agent_runner_request_session_id( @@ -1031,6 +1031,103 @@ pub(super) fn handle_external_agent_runner_request( } match request.method.as_str() { + // 编辑器使用自身的有界并发门闩;不能持有 Runtime 全局写请求缓存锁等待 Unity。 + "unity.editor.rpc" => { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct EditorCall { + method: String, + params: serde_json::Value, + #[serde(default)] + deadline_ms: Option, + } + let result = (|| { + if state.draining.load(Ordering::Acquire) { + return Err("Agent Runner 正在退出".to_string()); + } + let call: EditorCall = serde_json::from_value( + request.params.editor_rpc.clone().ok_or("缺少 editorRpc")?, + ) + .map_err(|_| "Unity RPC 参数无效".to_string())?; + if call + .deadline_ms + .is_some_and(|deadline| deadline <= unix_millis()) + { + return Err("Unity RPC 派发期限已过,未发送执行".to_string()); + } + crate::editor_adapters::unity_editor_rpc_owned( + &call.method, + call.params, + Some(&request.request_id), + ) + })(); + match result { + Ok(mut value) => { + if request + .params + .editor_rpc + .as_ref() + .and_then(|value| value["method"].as_str()) + == Some("execute") + { + if value.get("ackRequired").is_none() { + value["ackRequired"] = json!(false); + } + } + ExternalAgentRunnerResponse::success(&request.request_id, value) + } + Err(error) + if request + .params + .editor_rpc + .as_ref() + .and_then(|value| value["method"].as_str()) + == Some("execute") => + { + let mut value = crate::editor_adapters::unity_not_dispatched(&error); + value["ackRequired"] = json!(false); + ExternalAgentRunnerResponse::success(&request.request_id, value) + } + Err(error) => ExternalAgentRunnerResponse::failure( + &request.request_id, + "unity-editor-failed", + error, + ), + } + } + "unity.editor.ack" => { + let result = request + .params + .editor_rpc + .as_ref() + .and_then(|value| value["requestId"].as_str()) + .ok_or_else(|| "缺少 Unity 回执身份".to_string()) + .and_then(crate::editor_adapters::acknowledge_unity_editor_delivery); + match result { + Ok(()) => ExternalAgentRunnerResponse::success( + &request.request_id, + json!({"acknowledged":true}), + ), + Err(error) => ExternalAgentRunnerResponse::failure( + &request.request_id, + "unity-ack-failed", + error, + ), + } + } + "unity.editor.mark_uncertain" => { + match crate::editor_adapters::mark_unity_execution_uncertain() { + Ok(()) => ExternalAgentRunnerResponse::success( + &request.request_id, + json!({"status":"needs-reconciliation"}), + ), + Err(error) => ExternalAgentRunnerResponse::failure( + &request.request_id, + "unity-mark-failed", + error, + ), + } + } "runner.ping" => ExternalAgentRunnerResponse::success( &request.request_id, json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index de3ade0ae..b57eddf78 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -1022,6 +1022,23 @@ pub(crate) fn acquire_external_agent_runner_gui_participant_lock( config_dir: &Path, ) -> Result { let path = external_agent_runner_gui_participant_lock_path(config_dir); + // 恢复 Unity 未确认执行只允许在全部 GUI 和 Runner 退出后的首次打开。 + // 转为共享参与锁期间继续持有 Runner 独占锁,避免转换间隙启动执行 owner。 + let _fresh_gui_runner_guard = if let Some(participant) = + try_open_external_agent_runner_lock(&path, "AGC 界面参与锁")? + { + let runner = try_open_external_agent_runner_lock( + &external_agent_runner_lock_path(config_dir), + "Agent Runner 单实例锁", + )?; + if runner.is_some() { + crate::editor_adapters::reset_unity_execution_fence_for_fresh_gui(config_dir)?; + } + drop(participant); + runner + } else { + None + }; let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT; let mut last_error = "AGC 界面参与锁未知失败".to_string(); loop { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 5f4ae187c..434c1c0a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -24,7 +24,8 @@ pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_PATH: &str = ".agent/runtime/execution-owner.lock"; pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH: &str = ".agent/runtime/execution-owner.json"; -pub(super) const EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES: usize = 1024 * 1024; +// Unity helper 最多返回 2 MiB,额外保留 JSON 转义和 Runner 信封空间。 +pub(super) const EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES: u64 = 64 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_OWNER_BYTES: u64 = 16 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS: usize = 32; @@ -252,6 +253,8 @@ impl ExternalAgentRunnerStatus { #[derive(Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct ExternalAgentRunnerRequestParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) editor_rpc: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) root: Option, #[serde(default, alias = "agentId", skip_serializing_if = "Option::is_none")] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index 998ca0e9f..701699b04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -213,6 +213,7 @@ pub(crate) fn run_external_agent_runner_server( let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release); crate::set_game_creator_runtime_config_dir(config_dir.clone()); + crate::editor_adapters::configure_unity_helper_for_runtime()?; set_external_agent_runner_config_dir(config_dir.clone()); let boot_id = random_identifier(b"genarrative-agent-runner-boot-id")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index f1d2f7a39..5d35f0c71 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -20,6 +20,50 @@ use crate::{ static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0); +#[test] +fn unity_pending_execution_survives_new_window_and_runner_restart_until_full_gui_restart() { + let directory = unique_test_directory(); + let config = private_runner_test_config_dir(&directory); + let first = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + let fence = crate::editor_adapters::unity_execution_fence_path(&config); + fs::write(&fence, "unknown-request").unwrap(); + crate::editor_adapters::mark_unity_execution_uncertain_at(&config).unwrap(); + let uncertain_fence = crate::editor_adapters::unity_uncertain_fence_path(&config); + let second = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + assert!( + fence.exists(), + "new window must not clear pending execution" + ); + drop(second); + let runner = acquire_external_agent_runner_instance_lock( + &external_agent_runner_lock_path(&config), + "unity-test-boot", + ) + .unwrap(); + drop(first); + let while_runner_alive = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + assert!(fence.exists(), "running owner prevents recovery"); + drop(while_runner_alive); + drop(runner); + let restarted_runner = acquire_external_agent_runner_instance_lock( + &external_agent_runner_lock_path(&config), + "unity-test-boot-2", + ) + .unwrap(); + assert!( + fence.exists(), + "automatic Runner restart must not clear pending execution" + ); + assert!(uncertain_fence.exists()); + drop(restarted_runner); + let _fresh = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + assert!( + !fence.exists(), + "all GUI and Runner exited: a fresh GUI can recover" + ); + assert!(!uncertain_fence.exists()); +} + struct TestDirectoryGuard(PathBuf); impl Drop for TestDirectoryGuard { diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 6b1e7b8b9..da12b43c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -5,6 +5,10 @@ //! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。 use super::*; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, + with_validated_platform_session_identity, PlatformSessionIdentity, PlatformSessionSnapshot, +}; use serde::{Deserialize, Serialize}; const TEMPLATE_LIBRARY_SCHEMA_VERSION: &str = "agc-template-library.v1"; @@ -24,6 +28,74 @@ const TEMPLATE_ARCHIVE_MAX_FILES: usize = 4_096; const TEMPLATE_ARCHIVE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; const TEMPLATE_ID_MAX_CHARS: usize = 64; const TEMPLATE_VERSION_MAX_CHARS: usize = 32; +const TEMPLATE_ACCESS_ERROR: &str = "template-library-unavailable: 模板库暂未向当前账号开放"; + +async fn template_library_access_for_session( + session: &PlatformSessionSnapshot, +) -> Result { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?; + let response = client + .get(format!( + "{}/api/runtime/frontend-config", + session.api_base_url.trim_end_matches('/') + )) + .bearer_auth(&session.access_token) + .send() + .await + .map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?; + validate_platform_session_identity(&session.identity())?; + if !response.status().is_success() { + return Err(format!( + "template-library-unavailable: 检查模板库权限返回 HTTP {}", + response.status().as_u16() + )); + } + const MAX_BYTES: usize = 64 * 1024; + if response + .content_length() + .is_some_and(|length| length > MAX_BYTES as u64) + { + return Err("template-library-unavailable: 模板库权限响应无效".to_string()); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?; + if bytes.len() + chunk.len() > MAX_BYTES { + return Err("template-library-unavailable: 模板库权限响应无效".to_string()); + } + bytes.extend_from_slice(&chunk); + } + validate_platform_session_identity(&session.identity())?; + let payload: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?; + Ok(payload + .get("agcTemplateLibraryEnabled") + .and_then(|value| value.as_bool()) + == Some(true)) +} + +async fn require_template_library_access() -> Result { + let session = current_platform_session().ok_or_else(|| TEMPLATE_ACCESS_ERROR.to_string())?; + if !template_library_access_for_session(&session).await? { + return Err(TEMPLATE_ACCESS_ERROR.to_string()); + } + Ok(session.identity()) +} + +#[tauri::command] +pub(crate) async fn get_game_template_library_access() -> Result { + let Some(session) = current_platform_session() else { + return Ok(false); + }; + template_library_access_for_session(&session).await +} /// 远端清单里的单个模板条目(`templates/index.json` 中的 `templates[]`)。 #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] @@ -719,7 +791,9 @@ async fn ensure_template_installed( cache_root: &Path, template_id: &str, template_version: &str, + identity: &PlatformSessionIdentity, ) -> Result { + validate_platform_session_identity(identity)?; let installed_directory = installed_template_dir(cache_root, template_id, template_version)?; if let Some(record) = read_installed_record(&installed_directory) { return Ok(record); @@ -728,13 +802,16 @@ async fn ensure_template_installed( let client = build_template_library_client(); let url = template_object_url(&summary.zip_key)?; let bytes = fetch_limited_bytes(&client, &url, TEMPLATE_ARCHIVE_MAX_BYTES).await?; - install_template_archive(cache_root, &summary, &bytes) + with_validated_platform_session_identity(identity, || { + install_template_archive(cache_root, &summary, &bytes) + }) } #[tauri::command] pub(crate) async fn fetch_game_template_library( app: tauri::AppHandle, ) -> Result { + let identity = require_template_library_access().await?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; let index_url = format!( @@ -749,7 +826,10 @@ pub(crate) async fn fetch_game_template_library( let body = String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?; parse_game_template_library_index(&body)?; - write_cached_index(&cache_root, &body); + with_validated_platform_session_identity(&identity, || { + write_cached_index(&cache_root, &body); + Ok(()) + })?; (body, "network") } Err(error) => match read_cached_index(&cache_root) { @@ -760,6 +840,7 @@ pub(crate) async fn fetch_game_template_library( None => return Err(error), }, }; + validate_platform_session_identity(&identity)?; let (header, templates) = parse_game_template_library_index(&body)?; let installed = collect_installed_records(&cache_root); let entries = templates @@ -789,10 +870,17 @@ pub(crate) async fn download_game_template( template_id: String, template_version: String, ) -> Result { + let identity = require_template_library_access().await?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = - ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, + ) + .await?; + validate_platform_session_identity(&identity)?; Ok(InstalledGameTemplate { template_id: record.template_id, template_version: record.template_version, @@ -882,23 +970,137 @@ pub(crate) async fn create_automatic_local_game_project_from_template( planning: Option, projects_root: Option, ) -> Result { + let identity = require_template_library_access().await?; let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = - ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; - create_project_from_installed_template_at( - &projects_root, - Path::new(&record.project_dir), - name.as_deref(), - planning.unwrap_or(false), + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, ) + .await?; + with_validated_platform_session_identity(&identity, || { + create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + name.as_deref(), + planning.unwrap_or(false), + ) + }) } #[cfg(test)] mod tests { use super::*; + fn access_server( + status: u16, + body: &str, + change_identity: bool, + ) -> (String, std::thread::JoinHandle) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let body = body.to_string(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let size = socket.read(&mut buffer).unwrap(); + assert!(size > 0); + request.extend_from_slice(&buffer[..size]); + } + if change_identity { + crate::platform_session::clear_platform_session(2, 2); + } + write!(socket, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + String::from_utf8(request).unwrap() + }); + (url, server) + } + + #[tokio::test] + async fn template_access_requires_current_account_and_explicit_server_grant() { + for (status, body, allowed) in [ + (200, r#"{"agcTemplateLibraryEnabled":true}"#, true), + (200, r#"{"agcTemplateLibraryEnabled":false}"#, false), + (200, r#"{"imageEditorAgentSidebarEnabled":true}"#, false), + (200, r#"{"agcTemplateLibraryEnabled":"true"}"#, false), + (503, r#"{"agcTemplateLibraryEnabled":true}"#, false), + (200, "invalid JSON", false), + ] { + let (origin, server) = access_server(status, body, false); + let _session = crate::platform_session::install_test_platform_session( + "template-user", + "template-test-token", + &origin, + ); + assert_eq!(require_template_library_access().await.is_ok(), allowed); + let request = server.join().unwrap().to_lowercase(); + assert!(request.starts_with("get /api/runtime/frontend-config ")); + assert!(request.contains("authorization: bearer template-test-token")); + } + let _session = crate::platform_session::clear_test_platform_session(); + assert!(!get_game_template_library_access().await.unwrap()); + assert!(require_template_library_access().await.is_err()); + } + + #[tokio::test] + async fn template_access_preserves_identity_during_token_rotation() { + let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, false); + let _session = crate::platform_session::install_test_platform_session( + "template-user", + "old-token", + &origin, + ); + let frozen = current_platform_session().unwrap(); + crate::platform_session::install_platform_session( + "template-user", + "new-token", + &origin, + 1, + 2, + ) + .unwrap(); + assert!(template_library_access_for_session(&frozen).await.unwrap()); + server.join().unwrap(); + } + + #[tokio::test] + async fn template_access_rejects_old_account_response_and_cached_install() { + let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, true); + let _session = crate::platform_session::install_test_platform_session( + "template-user", + "template-test-token", + &origin, + ); + let identity = current_platform_session().unwrap().identity(); + let error = require_template_library_access().await.unwrap_err(); + assert!(error.contains("authentication-required")); + server.join().unwrap(); + let error = ensure_template_installed( + Path::new("unused-cache"), + "demo-template", + "0.1.0", + &identity, + ) + .await + .unwrap_err(); + assert!(error.contains("authentication-required")); + assert!( + with_validated_platform_session_identity::<()>(&identity, || panic!( + "旧会话不得写入项目" + )) + .is_err() + ); + } + fn sample_index_body() -> String { serde_json::json!({ "schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs index 90c1367c8..225bdc6a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs @@ -35,7 +35,7 @@ fn register_asset_delete_fixture_asset(root: &Path, relative_path: &str, task_id register_local_asset_at( root, relative_path, - "character", + GameCreationAppAssetKind::Character, "image/png", "generated", generated_asset_source(task_id), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs index 21764f49f..83068345d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs @@ -9,7 +9,7 @@ fn asset_rename_project_fixture() -> (PathBuf, String) { let asset = register_local_asset_at( &root, "assets/hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas", asset_rename_source(), @@ -192,7 +192,7 @@ fn rename_local_project_asset_rejects_extension_changes() { let extensionless = register_local_asset_at( &root, "assets/notes", - "asset", + GameCreationAppAssetKind::Document, "text/plain", "canvas", asset_rename_source(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 89a8d46e4..345c4f064 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1401,7 +1401,11 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r ); init_local_game_project_at(&root, "project-canvas-repair-atomic", "月光厨房") .expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); bind_canvas_visual_asset_fixture_to_current_editor( &root, "assets/art-spec.png", @@ -1586,7 +1590,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r &serde_json::json!({ "prompt": "生成原创晶体与潮汐构装体图集", "outputPath": "assets/art-spritesheet.png", - "assetKind": "art-spritesheet", + "assetKind": "icon-spritesheet", "sliceMode": "connected-components", "replaceExisting": true }), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 6f5317d98..e01062cdc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -3599,28 +3599,32 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String { base_url } -fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &str) { +fn register_canvas_visual_asset_fixture( + root: &Path, + local_path: &str, + kind: GameCreationAppAssetKind, +) { let absolute_path = root.join(local_path); fs::create_dir_all(absolute_path.parent().expect("visual asset parent")) .expect("create visual asset fixture directory"); - let bytes = if kind == "art-spritesheet" { + let bytes = if kind == GameCreationAppAssetKind::IconSpritesheet { transparent_test_png_bytes() } else { valid_test_png_bytes() }; fs::write(&absolute_path, bytes).expect("write visual asset fixture"); let (generation_route, generation_kind, reference_resource_ids) = match kind { - "icon-spec" => ( + GameCreationAppAssetKind::IconSpec => ( "/api/external/v1/editor/images/generations", "spec", Vec::new(), ), - "ui-prototype" => ( + GameCreationAppAssetKind::UiDesign => ( "/api/external/v1/editor/images/generations", "ui-design", vec!["resource-icon-spec".to_string()], ), - "art-spritesheet" => ( + GameCreationAppAssetKind::IconSpritesheet => ( "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", vec!["resource-icon-spec".to_string()], diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5aedeadd0..30ef9cd1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -369,9 +369,21 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re let root = unique_project_path(); init_local_game_project_at(&root, "visual-provenance", "视觉来源门禁") .expect("init visual provenance project"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ); let mut manifest = read_manifest_for_project(&root).expect("read visual manifest"); for task_id in ["art-director", "design-foundation", "art-asset-plan"] { validate_manifest_required_visual_asset(&root, &manifest, task_id) @@ -776,7 +788,11 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { &canvas_base_url, ); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); bind_canvas_visual_asset_fixture_to_current_editor( &root, "assets/art-spec.png", @@ -804,7 +820,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "outputPath": "assets/art-spritesheet.png", "aspectRatio": "1:1", "imageSize": "1K", - "assetKind": "art-spritesheet", + "assetKind": "icon-spritesheet", "assetLabel": "游戏首版核心美术素材", "replaceExisting": false, "sliceMode": "grid", @@ -930,7 +946,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { asset["source"]["referenceResourceIds"], serde_json::json!(["resource-icon-spec"]) ); - assert_eq!(asset["kind"], "art-spritesheet"); + assert_eq!(asset["kind"], "icon-spritesheet"); assert_eq!(asset["localPath"], "assets/art-spritesheet.png"); assert_eq!( fs::read(root.join(asset["localPath"].as_str().unwrap())).unwrap(), @@ -1146,7 +1162,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { &spec_config_dir, PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spec.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "游戏统一视觉规范图".to_string(), ..PlatformArtAssetGenerationOptions::default() }, @@ -1157,7 +1173,11 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { assert!(spec_request.contains(r#""assetKind":"icon-spec""#)); assert!(spec_request.contains(r#""referenceImageSrcs":[]"#)); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); let ui_config_dir = unique_project_path(); let ui_request = capture_generation_request( &root, @@ -1166,7 +1186,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { output_path: Some("assets/ui-prototype.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), + asset_kind: GameCreationAppAssetKind::UiDesign, asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, @@ -1184,7 +1204,11 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#)); // 用户参考只接受当前项目已登记图片素材 id,并换成当前账号绑定下的远端资源 ID。 - register_canvas_visual_asset_fixture(&root, "assets/user-reference.png", "image"); + register_canvas_visual_asset_fixture( + &root, + "assets/user-reference.png", + GameCreationAppAssetKind::Image, + ); let user_reference_asset_id = manifest_asset_id(&root, "assets/user-reference.png"); let icon_spec_config_dir = unique_project_path(); let icon_spec_with_reference = capture_generation_request( @@ -1192,7 +1216,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { &icon_spec_config_dir, PlatformArtAssetGenerationOptions { output_path: Some("assets/icon-spec-custom.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "带用户参考的图标规范".to_string(), reference_asset_ids: vec![ user_reference_asset_id.clone(), @@ -1217,7 +1241,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { output_path: Some("assets/ui-prototype-custom.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), + asset_kind: GameCreationAppAssetKind::UiDesign, asset_label: "带用户参考的界面原型图".to_string(), reference_asset_ids: vec![user_reference_asset_id.clone()], ..PlatformArtAssetGenerationOptions::default() @@ -1297,14 +1321,15 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po }, ) .expect("allow reference guard generation"); - let options = - |asset_kind: &str, reference_asset_ids: Vec| PlatformArtAssetGenerationOptions { + let options = |asset_kind: GameCreationAppAssetKind, reference_asset_ids: Vec| { + PlatformArtAssetGenerationOptions { output_path: Some(format!("assets/reference-guard-{asset_kind}.png")), - asset_kind: asset_kind.to_string(), + asset_kind, asset_label: "参考素材门禁".to_string(), reference_asset_ids, ..PlatformArtAssetGenerationOptions::default() - }; + } + }; async fn reject(root: &Path, options: PlatformArtAssetGenerationOptions) -> String { request_platform_art_asset_with_options_for_test(root, "参考素材门禁", &options) .await @@ -1326,7 +1351,10 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po // 只接受单规范引用的图集不接受用户参考,且必须明确拒绝而不是静默丢弃。 let error = reject( &root, - options("art-spritesheet", vec!["user-asset".to_string()]), + options( + GameCreationAppAssetKind::IconSpritesheet, + vec!["user-asset".to_string()], + ), ) .await; assert!(error.contains("透明美术图集只接受规范图引用"), "{error}"); @@ -1337,7 +1365,14 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po "..\\hero.png", "https://example.com/hero.png", ] { - let error = reject(&root, options("icon-spec", vec![rejected.to_string()])).await; + let error = reject( + &root, + options( + GameCreationAppAssetKind::IconSpec, + vec![rejected.to_string()], + ), + ) + .await; assert!(error.contains("不接受路径或远端资源 ID"), "{error}"); } @@ -1345,7 +1380,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec!["editor-resource-from-older-account".to_string()], ), ) @@ -1361,7 +1396,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po register_local_asset_at( &root, "assets/document.json", - "image", + GameCreationAppAssetKind::Image, "application/json", "canvas", canvas_source(), @@ -1370,7 +1405,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec![manifest_asset_id(&root, "assets/document.json")], ), ) @@ -1378,18 +1413,26 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po assert!(error.contains("参考素材必须是图片"), "{error}"); // 已登记但本地文件缺失的素材不能被引用。 - register_canvas_visual_asset_fixture(&root, "assets/missing-reference.png", "image"); + register_canvas_visual_asset_fixture( + &root, + "assets/missing-reference.png", + GameCreationAppAssetKind::Image, + ); let missing_asset_id = manifest_asset_id(&root, "assets/missing-reference.png"); fs::remove_file(root.join("assets/missing-reference.png")) .expect("remove missing reference fixture file"); - let error = reject(&root, options("icon-spec", vec![missing_asset_id])).await; + let error = reject( + &root, + options(GameCreationAppAssetKind::IconSpec, vec![missing_asset_id]), + ) + .await; assert!(error.contains("不存在;请重新登记后再引用"), "{error}"); // 超过总上限:无规范前置最多 5 张。 let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, (0..6).map(|index| format!("reference-{index}")).collect(), ), ) @@ -1397,7 +1440,11 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po assert!(error.contains("普通图片生成最多 5 张参考素材"), "{error}"); // SVG 与坏图同样必须在提交前失败:本次不做 SVG 转换,也不允许把「已登记」当成可解码。 - register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + register_canvas_visual_asset_fixture( + &root, + "assets/plain-reference.png", + GameCreationAppAssetKind::Image, + ); let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); fs::write( root.join("assets/vector-reference.svg"), @@ -1407,7 +1454,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po register_local_asset_at( &root, "assets/vector-reference.svg", - "image", + GameCreationAppAssetKind::Image, "image/svg+xml", "canvas", canvas_source(), @@ -1417,7 +1464,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec![plain_reference_asset_id.clone(), vector_asset_id], ), ) @@ -1433,7 +1480,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po register_local_asset_at( &root, "assets/mislabeled-reference.svg", - "image", + GameCreationAppAssetKind::Image, "image/png", "canvas", canvas_source(), @@ -1443,7 +1490,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec![plain_reference_asset_id.clone(), mislabeled_asset_id], ), ) @@ -1455,7 +1502,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po register_local_asset_at( &root, "assets/broken-reference.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "canvas", canvas_source(), @@ -1465,7 +1512,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec![plain_reference_asset_id.clone(), broken_asset_id], ), ) @@ -1483,7 +1530,10 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let differential = request_platform_art_asset_with_options_for_test( &root, "参考素材门禁", - &options("icon-spec", vec![plain_reference_asset_id.clone()]), + &options( + GameCreationAppAssetKind::IconSpec, + vec![plain_reference_asset_id.clone()], + ), ) .await; let mut upload_ticket_attempted = false; @@ -1534,7 +1584,11 @@ async fn reference_upload_permission_gate_fails_closed_before_any_upload() { }, ) .expect("deny reference upload"); - register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + register_canvas_visual_asset_fixture( + &root, + "assets/plain-reference.png", + GameCreationAppAssetKind::Image, + ); let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); let error = request_platform_art_asset_with_options_for_test( @@ -1542,7 +1596,7 @@ async fn reference_upload_permission_gate_fails_closed_before_any_upload() { "参考素材门禁", &PlatformArtAssetGenerationOptions { output_path: Some("assets/reference-permission.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "参考素材门禁".to_string(), reference_asset_ids: vec![plain_reference_asset_id], ..PlatformArtAssetGenerationOptions::default() @@ -1665,7 +1719,11 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m ); init_local_game_project_at(&root, "project-canvas-request-lock", "月光厨房") .expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); bind_canvas_visual_asset_fixture_to_current_editor( &root, "assets/art-spec.png", @@ -1704,7 +1762,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m "生成首版花园素材", &PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, asset_label: "游戏首版核心美术素材".to_string(), slice_mode: Some("connected-components".to_string()), ..PlatformArtAssetGenerationOptions::default() @@ -2292,6 +2350,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { godot_project_root: None, is_cocos_project: false, cocos_project_root: None, + is_unity_project: false, + unity_project_root: None, project_name: None, modified_at: None, manifest_error: None, @@ -2393,6 +2453,66 @@ fn project_directory_status_detects_cocos_creator_project() { assert_eq!(detected.as_deref(), Some(".")); } +#[test] +fn unity_project_import_preserves_engine_files_and_existing_agent_metadata() { + let root = tempfile::tempdir().unwrap(); + for directory in ["Assets", "Packages", "ProjectSettings"] { + fs::create_dir(root.path().join(directory)).unwrap(); + } + fs::write( + root.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.1f1\n", + ) + .unwrap(); + fs::write(root.path().join("Assets/keep.txt"), "用户资源").unwrap(); + fs::write( + root.path().join("Packages/manifest.json"), + "{\"dependencies\":{}}", + ) + .unwrap(); + let status = + inspect_local_project_directory_sync(root.path().to_string_lossy().into_owned()).unwrap(); + assert!(status.is_unity_project); + assert_eq!(status.unity_project_root.as_deref(), Some(".")); + let initial = import_local_unity_project_at(root.path(), "unity-test", "Unity项目").unwrap(); + fs::write(root.path().join(".agent/keep.txt"), "用户元数据").unwrap(); + let reopened = import_local_unity_project_at(root.path(), "another-id", "另一个名称").unwrap(); + assert_eq!(initial.manifest.project_id, reopened.manifest.project_id); + assert_eq!( + fs::read_to_string(root.path().join("Assets/keep.txt")).unwrap(), + "用户资源" + ); + assert_eq!( + fs::read_to_string(root.path().join("Packages/manifest.json")).unwrap(), + "{\"dependencies\":{}}" + ); + assert_eq!( + fs::read_to_string(root.path().join(".agent/keep.txt")).unwrap(), + "用户元数据" + ); + assert!(root + .path() + .join("ProjectSettings/ProjectVersion.txt") + .is_file()); +} + +#[test] +fn unity_project_identity_requires_all_engine_directories() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("ProjectSettings")).unwrap(); + fs::write( + root.path().join("ProjectSettings/ProjectVersion.txt"), + "version", + ) + .unwrap(); + assert_eq!( + discover_local_unity_project_root(root.path()).unwrap(), + None + ); + assert!(import_local_unity_project_at(root.path(), "id", "Unity项目").is_err()); + assert!(!root.path().join(".agent").exists()); +} + #[test] fn project_directory_status_reports_workspace_relative_godot_root() { let root = unique_project_path(); @@ -2496,7 +2616,7 @@ fn upload_local_asset_writes_file_and_manifest_entry() { assert_eq!(manifest["assets"][0]["mediaType"], "image/png"); assert_eq!(manifest["assets"][0]["source"]["kind"], "uploaded"); // 落盘 `kind` 只能由内容证据推导:图片是 `image`(→ `unclassified` → 「待归类」)。 - // 不许写 `uploaded`(来源词当类型),更不许把 `ui` 当图片默认值——那会把任意上传图片 + // 不许写 `uploaded`(来源词当类型),也不能把图片默认归入 UI 交互——那会把任意上传图片 // 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来。 assert_eq!(manifest["assets"][0]["kind"], "image"); assert_eq!(manifest["assets"][0]["category"], "unclassified"); @@ -2523,10 +2643,8 @@ fn upload_local_asset_writes_file_and_manifest_entry() { .as_array() .unwrap() .iter() - .any(|asset| asset["kind"] == "uploaded" - || asset["kind"] == "ui" - || asset["category"] == "ui-interaction"), - "上传登记不得产出 `uploaded` / `ui` / `ui-interaction`:{manifest}" + .any(|asset| asset["kind"] == "uploaded" || asset["category"] == "ui-interaction"), + "上传登记不得产出 `uploaded` / `ui-interaction`:{manifest}" ); fs::remove_dir_all(root).ok(); @@ -2550,7 +2668,7 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { let result = register_local_asset_at( &root, "assets/hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas", GameCreationAppAssetSource { @@ -2593,7 +2711,7 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { let updated = register_local_asset_at( &root, "assets/hero.png", - "ui", + GameCreationAppAssetKind::UiDesign, "image/png", "generated", GameCreationAppAssetSource { @@ -2615,7 +2733,7 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); assert_eq!(manifest["assets"].as_array().unwrap().len(), 1); - assert_eq!(manifest["assets"][0]["kind"], "ui"); + assert_eq!(manifest["assets"][0]["kind"], "ui-design"); // kind 变了必须重派生 category:旧分类 `character` 是非 unclassified 值,会被读侧 // 无条件信任、自愈也不会触发,资产就永远停在「角色与对象」。 assert_eq!(manifest["assets"][0]["category"], "ui-interaction"); @@ -2649,8 +2767,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() { let registered = register_local_asset_at( &root, "ui/UI 设计 1.json", - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, "ui-workflow", source(), ) @@ -2671,8 +2789,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() { register_local_asset_at( &root, "ui/UI 设计 1.json", - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, "ui-workflow", source(), ) @@ -2681,20 +2799,22 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() { let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); - assert_eq!(manifest["assets"][0]["kind"], "UI"); + assert_eq!( + manifest["assets"][0]["kind"], + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND + ); assert_eq!(manifest["assets"][0]["category"], "audio"); assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"])); fs::remove_dir_all(root).ok(); } -/// 写侧 → 分类的端到端口径:现役写入侧直接写出的非 canonical kind 字面量必须落进明确栏目。 +/// 写侧 → 分类的端到端口径:现役写入侧直接写出的 canonical kind 必须落进明确栏目。 /// /// 这里的字面量与写入侧逐字一致:UI 设计资产是 /// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的 -/// `register_local_asset_at(root, path, "UI", "application/json", ...)`, +/// `register_local_asset_at` 使用 UI 文档 kind/media 常量, /// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。 -/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。 #[test] fn register_local_asset_derives_category_from_real_write_side_kinds() { let root = unique_project_path(); @@ -2717,8 +2837,8 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() { register_local_asset_at( &root, "ui/UI 设计 1.json", - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, "ui-workflow", source(), ) @@ -2726,7 +2846,7 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() { register_local_asset_at( &root, "assets/ui-font.ttf", - "font", + GameCreationAppAssetKind::Font, "font/ttf", "font", source(), @@ -2751,14 +2871,49 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() { assert_eq!( categories, vec![ - ("UI".to_string(), "ui-interaction".to_string()), ("font".to_string(), "document".to_string()), + ( + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND + .to_string(), + "ui-interaction".to_string(), + ), ] ); fs::remove_dir_all(root).ok(); } +/// 已存在的 `ui/UI 设计 N.json` 不能让新建 UI 设计资源直接失败。 +/// +/// 编号按已登记的 UI 文档数推导,旧项目里 kind 已被收口为 `unknown` 的文档不再计入, +/// 只按计数取名就会撞上仍然存在的同名文件并报「路径已存在」。两个写入侧必须共用 +/// `ui_editor::resource_bridge` 的「第一个空闲编号」规则,既不改名既有文件也不覆盖。 +#[test] +fn create_ui_design_resource_skips_a_taken_ui_design_path() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "UI 设计编号避让").expect("project init"); + write_local_project_file_at(&root, "ui/UI 设计 1.json", "{}").expect("ui asset file"); + + let result = crate::commands::create_ui_design_resource( + root.to_string_lossy().to_string(), + "project-1".to_string(), + ) + .expect("create UI design resource"); + + assert_eq!(result.asset.local_path, "ui/UI 设计 2.json"); + let registered = result + .manifest + .assets + .iter() + .find(|asset| asset.id == result.asset.id) + .expect("registered UI design asset"); + assert_eq!(registered.source.resource_id.as_deref(), Some("ui:2")); + assert_eq!(registered.kind, GameCreationAppAssetKind::UiDesignDoc); + assert!(root.join("ui/UI 设计 1.json").is_file()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn register_local_asset_rejects_missing_or_unsafe_path() { let root = unique_project_path(); @@ -2778,7 +2933,7 @@ fn register_local_asset_rejects_missing_or_unsafe_path() { assert!(register_local_asset_at( &root, "../outside.png", - "asset", + GameCreationAppAssetKind::Image, "image/png", "generated", source.clone() @@ -2787,7 +2942,7 @@ fn register_local_asset_rejects_missing_or_unsafe_path() { assert!(register_local_asset_at( &root, "assets/missing.png", - "asset", + GameCreationAppAssetKind::Image, "image/png", "generated", source @@ -2805,7 +2960,7 @@ fn import_canvas_asset_registers_canvas_source_metadata() { let result = import_canvas_asset_at( &root, "assets/canvas-hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas-project-1", Some("resource-1".to_string()), @@ -2851,7 +3006,7 @@ fn import_canvas_asset_requires_traceable_canvas_ids() { let missing_project = import_canvas_asset_at( &root, "assets/canvas-hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "", Some("resource-1".to_string()), @@ -2866,7 +3021,7 @@ fn import_canvas_asset_requires_traceable_canvas_ids() { let missing_asset = import_canvas_asset_at( &root, "assets/canvas-hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas-project-1", None, @@ -3136,7 +3291,8 @@ async fn generate_platform_art_asset_downloads_and_registers_external_image() { serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); let asset = &manifest["assets"][0]; - assert_eq!(asset["kind"], "game-art"); + // 平台请求词汇 `game-art` 在写入边界收口成正式成员 `image`。 + assert_eq!(asset["kind"], "image"); assert_eq!(asset["mediaType"], "image/png"); assert_eq!(asset["source"]["kind"], "canvas"); assert_eq!(asset["source"]["canvasProjectId"], "canvas-project-1"); @@ -3315,7 +3471,7 @@ async fn generate_local_project_asset_command_lands_the_requested_target_categor } #[tokio::test] -async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_spec_channel() { +async fn generate_local_project_asset_command_registers_icon_spec_with_the_spec_generation_kind() { let root = unique_project_path(); let config_dir = unique_project_path(); let base_url = spawn_mock_external_canvas_generation_api_server(None); @@ -3342,7 +3498,7 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s let asset = generate_local_project_asset( root.to_string_lossy().into_owned(), - "spec".to_string(), + "icon-spec".to_string(), "原创收集玩法的统一视觉规范".to_string(), None, None, @@ -3363,7 +3519,7 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s .iter() .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) .expect("registered toolbar spec asset"); - // spec 必须收口到客户端验证过的 icon-spec,否则规范图无法作为 UI 原型与透明图集的权威参考。 + // 工具栏直接给 canonical kind;平台请求词汇 `spec` 只出现在 generationKind,不再参与登记。 assert_eq!(entry["kind"], "icon-spec"); assert_eq!(entry["source"]["generationKind"], "spec"); assert_eq!( @@ -3382,7 +3538,8 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s } #[tokio::test] -async fn generate_local_project_asset_command_generates_art_spritesheet_from_the_registered_spec() { +async fn generate_local_project_asset_command_generates_icon_spritesheet_from_the_registered_spec() +{ let root = unique_project_path(); let config_dir = unique_project_path(); let base_url = spawn_mock_external_canvas_generation_api_server(None); @@ -3410,7 +3567,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the // 透明图集必须有权威规范图:没有已登记的 icon-spec 时必须明确失败,不能静默降级成普通图。 let missing_reference = generate_local_project_asset( root.to_string_lossy().into_owned(), - "art-spritesheet".to_string(), + "icon-spritesheet".to_string(), "原创收集玩法素材图集".to_string(), None, None, @@ -3426,7 +3583,11 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the "{missing_reference}" ); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); bind_canvas_visual_asset_fixture_to_current_editor( &root, "assets/art-spec.png", @@ -3435,7 +3596,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the let asset = generate_local_project_asset( root.to_string_lossy().into_owned(), - "art-spritesheet".to_string(), + "icon-spritesheet".to_string(), "原创收集玩法素材图集".to_string(), Some("1:1".to_string()), Some("1K".to_string()), @@ -3457,7 +3618,8 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the .iter() .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) .expect("registered toolbar spritesheet asset"); - assert_eq!(entry["kind"], "art-spritesheet"); + // 工具栏直接给 canonical kind;平台请求词汇 `icon-spritesheet` 只出现在 generationKind。 + assert_eq!(entry["kind"], "icon-spritesheet"); assert_eq!( entry["source"]["generationRoute"], "/api/external/v1/editor/icon-spritesheets/generations" @@ -6183,7 +6345,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { output_path: Some("assets/ui-prototype.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), + asset_kind: GameCreationAppAssetKind::UiDesign, asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, @@ -6241,7 +6403,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, asset_label: "游戏首版核心美术素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 8709bc5b0..7636655ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -6105,7 +6105,7 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { register_local_asset_at( &root, "assets/preview.png", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, "image/png", "canvas", GameCreationAppAssetSource { @@ -6219,7 +6219,7 @@ fn local_project_resource_previews_require_registered_safe_resources() { register_local_asset_at( &root, "game/design.md", - "document", + GameCreationAppAssetKind::Document, "text/markdown", "generated", source(), @@ -6228,7 +6228,7 @@ fn local_project_resource_previews_require_registered_safe_resources() { register_local_asset_at( &root, "assets/icon.svg", - "icon", + GameCreationAppAssetKind::Icon, "image/svg+xml", "generated", source(), @@ -6237,7 +6237,7 @@ fn local_project_resource_previews_require_registered_safe_resources() { register_local_asset_at( &root, "assets/bgm.mp3", - "bgm", + GameCreationAppAssetKind::BackgroundMusic, "audio/mpeg", "generated", source(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 8fef84893..835d2ce16 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -6955,15 +6955,14 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"], serde_json::json!(["connected-components", "grid", null]) ); + let mut expected_asset_kinds = AGENT_RUNTIME_CANVAS_ASSET_KINDS + .iter() + .map(|kind| serde_json::Value::String(kind.as_str().to_string())) + .collect::>(); + expected_asset_kinds.push(serde_json::Value::Null); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], - serde_json::json!([ - "game-art", - "icon-spec", - "ui-prototype", - "art-spritesheet", - null - ]) + serde_json::Value::Array(expected_asset_kinds) ); let preview_name = native_runtime_function_name("preview.validate").expect("preview name"); @@ -7271,7 +7270,7 @@ fn local_asset_prompt_context_summarizes_uploaded_and_canvas_assets() { import_canvas_asset_at( &root, "assets/canvas-hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas-project-1", Some("resource-1".to_string()), @@ -7317,7 +7316,7 @@ fn local_asset_prompt_context_discovers_unregistered_media_without_formal_identi import_canvas_asset_at( &root, "assets/registered.png", - "ui", + GameCreationAppAssetKind::UiDesign, "image/png", "canvas-project-registered", Some("resource-registered".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index db84a285b..2ea327cae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -752,8 +752,16 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() { async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ); update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Completed) .expect("complete art spec dependency fixture"); write_project_permission_policy_at( @@ -1805,7 +1813,11 @@ async fn background_agent_runtime_image_inspect_sends_two_images_without_persist async fn design_ui_image_inspect_fails_scene_and_persists_canonical_checks() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "场景图拒绝测试").expect("project init"); - register_canvas_visual_asset_fixture(&root, AGENT_RUNTIME_UI_PROTOTYPE_PATH, "ui-prototype"); + register_canvas_visual_asset_fixture( + &root, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, + GameCreationAppAssetKind::UiDesign, + ); let (sender, receiver) = mpsc::channel(); let base_url = spawn_mock_llm_server_responses_with_capture( vec![ui_prototype_assessment_fixture(false)], @@ -1908,7 +1920,7 @@ fn image_inspect_safe_receipt_keeps_legacy_v1_audit_readable() { "responseId": "resp_legacy_ui_audit", "conclusionChars": 20, "conclusion": "历史 v1 审计只用于读取。", - "inspectionKind": AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, + "inspectionKind": AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, "validationProfile": AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, "passed": true, "checks": { @@ -2157,9 +2169,21 @@ fn seed_refresh_preserves_completed_visual_tasks_when_registered_file_is_missing ); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "旧视觉任务升级测试").expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ); let mut manifest = read_manifest_for_project(&root).expect("manifest with visual assets"); for task_id in ["art-director", "design-foundation", "art-asset-plan"] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs index 3aa224d0d..f68cc1abc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs @@ -468,9 +468,21 @@ fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_ ); } - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ); for task_id in ["art-director", "design-foundation", "art-asset-plan"] { update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) .expect("complete visual owner task with registered image"); @@ -3654,7 +3666,11 @@ async fn autonomous_static_art_asset_delivery_uses_runtime_owner_validation_with br#"{"assets":[{"path":"assets/art-spritesheet.png","kind":"art-spritesheet"}]}"#, ) .expect("write static art manifest after the parent completion baseline"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ); prepare_agent_runtime_project_mutation_locked( &root, "art-asset-plan", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index aa366c760..99478e8b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -98,11 +98,12 @@ pub(super) use crate::{ AgentRuntimePendingToolAction, AgentRuntimePlanUpdate, AgentRuntimePlanUpdateStep, AgentRuntimeProviderActionBatchPreparation, AgentRuntimeState, AgentRuntimeTaskLink, AgentRuntimeTaskRecord, AgentRuntimeToolAction, AgentRuntimeToolObservation, - AgentRuntimeToolPlan, AgentRuntimeToolPolicyBlock, GameCreationAppTaskStatus, - LocalConversationMessage, ProjectAgentPermissionPolicy, ProjectPermissionPolicy, - SupervisorCollaborationPolicy, AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION, - AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, AGENT_RUNTIME_AUTONOMOUS_LIVENESS_OBSERVATION_LIMIT, + AgentRuntimeToolPlan, AgentRuntimeToolPolicyBlock, GameCreationAppAssetKind, + GameCreationAppTaskStatus, LocalConversationMessage, ProjectAgentPermissionPolicy, + ProjectPermissionPolicy, SupervisorCollaborationPolicy, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, + AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION, AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + AGENT_RUNTIME_AUTONOMOUS_LIVENESS_OBSERVATION_LIMIT, AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS, AGENT_RUNTIME_AUTONOMOUS_TRUNCATED_SCAFFOLD_MAX_OUTPUT_TOKENS, @@ -114,9 +115,9 @@ pub(super) use crate::{ AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SCHEMA_VERSION, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION, - AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, - AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_PATH, + AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_CONFIG_FILE_NAME, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, GAME_CREATOR_USER_INPUT_REQUEST_TOOL, + PROJECT_BLACKBOARD_MEMORY_PATH, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs index b3c0221e1..2a8d3e389 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs @@ -42,7 +42,7 @@ fn replacement_generated_asset_source(task_id: &str) -> GameCreationAppAssetSour fn register_replacement_fixture_asset( root: &Path, relative_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, task_id: &str, ) -> String { @@ -146,14 +146,14 @@ fn replacement_rewrites_the_version_binding_without_adding_a_version() { let source_asset = register_replacement_fixture_asset( &root, "assets/hero-a.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-a", ); let untouched_asset = register_replacement_fixture_asset( &root, "assets/scene.png", - "scene", + GameCreationAppAssetKind::Scene, "image/png", "art-scene", ); @@ -162,7 +162,7 @@ fn replacement_rewrites_the_version_binding_without_adding_a_version() { let replacement_asset = register_replacement_fixture_asset( &root, "assets/hero-b.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-b", ); @@ -280,14 +280,14 @@ fn replacement_drops_source_binding_when_replacement_is_already_bound() { let source_asset = register_replacement_fixture_asset( &root, "assets/legacy.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-legacy", ); let already_bound_replacement = register_replacement_fixture_asset( &root, "assets/final.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-final", ); @@ -352,21 +352,21 @@ fn replacement_drops_source_binding_when_replacement_is_already_bound() { fn replacement_blocks_category_and_subtype_mismatch_but_only_hints_size_spec() { struct BlockingCase { name: &'static str, - source_kind: &'static str, - target_kind: &'static str, + source_kind: GameCreationAppAssetKind, + target_kind: GameCreationAppAssetKind, expected_reason: &'static str, } let blocking_cases = [ BlockingCase { name: "分类不同", - source_kind: "character", - target_kind: "scene", + source_kind: GameCreationAppAssetKind::Character, + target_kind: GameCreationAppAssetKind::Scene, expected_reason: "分类不同", }, BlockingCase { name: "类型不同", - source_kind: "character", - target_kind: "character-animation", + source_kind: GameCreationAppAssetKind::Character, + target_kind: GameCreationAppAssetKind::CharacterAnimation, expected_reason: "类型不同", }, ]; @@ -420,7 +420,7 @@ fn replacement_blocks_category_and_subtype_mismatch_but_only_hints_size_spec() { let source_asset = register_replacement_fixture_asset( &root, "assets/hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-hero", ); @@ -428,7 +428,7 @@ fn replacement_blocks_category_and_subtype_mismatch_but_only_hints_size_spec() { let webp_asset = register_replacement_fixture_asset( &root, "assets/hero.webp", - "character", + GameCreationAppAssetKind::Character, "image/webp", "art-hero-webp", ); @@ -486,14 +486,14 @@ fn replacement_hints_known_frame_size_and_duration_differences() { let source_asset = register_replacement_fixture_asset( &root, "assets/seq-source.png", - "character-animation", + GameCreationAppAssetKind::CharacterAnimation, "image/png", "anim-source", ); let target_asset = register_replacement_fixture_asset( &root, "assets/seq-target.png", - "character-animation", + GameCreationAppAssetKind::CharacterAnimation, "image/png", "anim-target", ); @@ -551,14 +551,14 @@ fn replacement_heals_persisted_unclassified_category_like_the_shared_contract() let source_asset = register_replacement_fixture_asset( &root, "assets/ui-a.png", - "ui-design", + GameCreationAppAssetKind::UiDesign, "image/png", "ui-a", ); let healed_asset = register_replacement_fixture_asset( &root, "assets/ui-b.png", - "ui-design", + GameCreationAppAssetKind::UiDesign, "image/png", "ui-b", ); @@ -611,7 +611,7 @@ fn replacement_rejects_unresolvable_source_and_target() { let bound_asset = register_replacement_fixture_asset( &root, "assets/bound.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-bound", ); @@ -620,7 +620,7 @@ fn replacement_rejects_unresolvable_source_and_target() { let unbound_asset = register_replacement_fixture_asset( &root, "assets/late.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-late", ); @@ -672,14 +672,14 @@ fn replacement_enforces_revision_and_identity_cas() { let source_asset = register_replacement_fixture_asset( &root, "assets/cas-source.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-cas-source", ); let target_asset = register_replacement_fixture_asset( &root, "assets/cas-target.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-cas-target", ); @@ -722,35 +722,35 @@ fn replacement_candidates_report_authoritative_compatibility() { let source_asset = register_replacement_fixture_asset( &root, "assets/pick-source.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-pick-source", ); let compatible_asset = register_replacement_fixture_asset( &root, "assets/pick-compatible.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-pick-compatible", ); let category_mismatch = register_replacement_fixture_asset( &root, "assets/pick-scene.png", - "scene", + GameCreationAppAssetKind::Scene, "image/png", "art-pick-scene", ); let subtype_mismatch = register_replacement_fixture_asset( &root, "assets/pick-animation.png", - "character-animation", + GameCreationAppAssetKind::CharacterAnimation, "image/png", "art-pick-animation", ); let format_mismatch = register_replacement_fixture_asset( &root, "assets/pick-webp.webp", - "character", + GameCreationAppAssetKind::Character, "image/webp", "art-pick-webp", ); @@ -814,7 +814,7 @@ fn replacement_candidates_report_authoritative_compatibility() { let late_asset = register_replacement_fixture_asset( &root, "assets/pick-late.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-pick-late", ); 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 797f8c44d..9d9b9bb3b 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 @@ -17,7 +17,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::Path; -use ts_rs::TS; pub const ASSET_BATCH_SIZE: usize = 5; @@ -62,16 +61,14 @@ struct BindingResponse { changes: Vec, } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct BindingChange { pub node_id: NodeId, pub component: NodeComponent, pub component_status: StageStatus, } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct BindingDTO { pub changes: Vec, } @@ -434,7 +431,6 @@ pub(crate) async fn bind_components_impl_with_provider( mod tests { use super::*; use crate::ui_editor::component::text::{FontSource, TextComponent}; - use ts_rs::{Config, TS}; fn id(value: &str) -> NodeId { NodeId::new(value).expect("valid id") @@ -637,13 +633,4 @@ mod tests { assert!(read_ui_reference_image_data_url(path).await.is_err()); } - - #[test] - fn exports_ui_editor_types() { - let config = Config::from_env(); - BindingDTO::export_all(&config).expect("BindingDTO TypeScript export succeeds"); - Node::export_all(&config).expect("Node TypeScript export succeeds"); - TextComponent::export_all(&config).expect("TextComponent TypeScript export succeeds"); - FontSource::export_all(&config).expect("FontSource TypeScript export succeeds"); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index a566a9225..43c045d4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -19,6 +19,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use typed_floats::tf32::StrictlyPositiveFinite; const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1"; +pub(crate) use shared_contracts::game_creation_app::{ + GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND as UI_DESIGN_DOC_ASSET_KIND, + GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE as UI_DESIGN_DOC_MEDIA_TYPE, +}; const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024; const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8; const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; @@ -158,7 +162,7 @@ pub(crate) fn load_ui_design_state_at( let root = Path::new(input.project_path.trim()); let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; validate_document(&document, &expected_project_id, &asset_id)?; @@ -175,7 +179,7 @@ pub(crate) fn generate_ui_design_code_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?; - let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?; @@ -225,9 +229,9 @@ pub(crate) fn save_ui_design_state_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let preflight_asset = registered_json_asset(root, &expected_project_id, &asset_id)?; + let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?; let _lock = acquire_project_write_lock(root, "ui_design.state_save")?; - let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; if asset.local_path != preflight_asset.local_path { return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string()); } @@ -314,7 +318,7 @@ fn ui_design_asset( ) -> Result { let asset = registered_json_asset(root, expected_project_id, asset_id)?; // 新状态初始化仍是显式 UI 创建动作,不能因放开已有设计的登记标签而覆盖普通 JSON。 - if asset.kind != "UI" || asset.media_type != "application/json" { + if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE { return Err("目标资源不是 UI 设计 JSON 资产".to_string()); } Ok(asset) @@ -847,8 +851,8 @@ mod tests { let asset = register_local_asset_at( directory.path(), relative_path, - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + UI_DESIGN_DOC_MEDIA_TYPE, "test", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -934,8 +938,11 @@ mod tests { } #[test] - fn json_preview_and_editor_accept_valid_state_without_rewriting_asset_kind() { - for kind in ["UI", "ui", "ui-design", "document"] { + fn json_preview_recognizes_registered_json_and_editor_requires_canonical_kind() { + for (kind, editor_allowed) in [ + (GameCreationAppAssetKind::UiDesignDoc, true), + (GameCreationAppAssetKind::Document, false), + ] { let (directory, asset_id) = fixture(); crate::project::mutate_manifest_at(directory.path(), |manifest| { manifest @@ -943,7 +950,7 @@ mod tests { .iter_mut() .find(|asset| asset.id == asset_id) .unwrap() - .kind = kind.to_string(); + .kind = kind; Ok(()) }) .unwrap(); @@ -963,20 +970,15 @@ mod tests { project_path: directory.path().to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), asset_id: asset_id.clone(), - }) - .unwrap(); - assert_eq!(loaded.revision, 0); + }); + assert_eq!(loaded.is_ok(), editor_allowed); let saved = save_ui_design_state_at(input( directory.path(), &asset_id, 0, state_with_unavailable_image("assets/page.png"), - )) - .unwrap(); - assert!(matches!( - saved, - SaveUiDesignStateResult::Saved { revision: 1, .. } )); + assert_eq!(saved.is_ok(), editor_allowed); assert_eq!( read_existing_manifest_for_project(directory.path()) .unwrap() @@ -999,7 +1001,7 @@ mod tests { .iter_mut() .find(|asset| asset.id == asset_id) .unwrap() - .kind = "document".to_string(); + .kind = GameCreationAppAssetKind::Document; Ok(()) }) .unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs index 410746488..dc76ce37c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs @@ -1,4 +1,7 @@ -use crate::ui_editor::persistence::initialize_ui_design_state_with_source_image_at; +use crate::ui_editor::persistence::{ + initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND, + UI_DESIGN_DOC_MEDIA_TYPE, +}; use crate::{ acquire_project_write_lock, advance_agent_runtime_project_revision_locked, enforce_project_permission_policy, read_existing_manifest_for_project, @@ -8,6 +11,7 @@ use crate::{ }; use image::GenericImageView; use serde::{Deserialize, Serialize}; +use shared_contracts::game_creation_app::GameCreationAppAssetKind; use std::fs; use std::path::Path; @@ -48,7 +52,7 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( .iter() .find(|asset| asset.id == prototype_asset_id) .ok_or_else(|| "UI 原型资产不存在".to_string())?; - if prototype.kind != "ui-prototype" + if prototype.kind != GameCreationAppAssetKind::UiDesign || !prototype .media_type .to_ascii_lowercase() @@ -89,8 +93,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( } if let Some(asset) = manifest.assets.iter().find(|asset| { - asset.kind == "UI" - && asset.media_type == "application/json" + asset.kind == UI_DESIGN_DOC_ASSET_KIND + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE && asset.source.reference_resource_ids.iter().any(|reference| { source_reference_ids .iter() @@ -118,8 +122,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( let asset = match register_local_asset_at( root, &relative_path, - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + UI_DESIGN_DOC_MEDIA_TYPE, "generated", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -186,14 +190,18 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( }) } -fn next_ui_design_path( +/// UI 设计文档的确定性命名:从已登记文档数 + 1 起找第一个既未被占用、 +/// 也未登记进 manifest 的 `ui/UI 设计 N.json`,不覆盖任何既有文件。 +pub(crate) fn next_ui_design_path( root: &Path, manifest: &GameCreationAppManifest, ) -> Result<(String, String), String> { let mut index = manifest .assets .iter() - .filter(|asset| asset.kind == "UI") + .filter(|asset| { + asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }) .count() + 1; loop { @@ -242,7 +250,7 @@ mod tests { register_local_asset_entry( directory.path(), "assets/ui-prototype.png", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, "image/png", "canvas", GameCreationAppAssetSource { @@ -270,7 +278,7 @@ mod tests { let source_id = manifest .assets .iter() - .find(|asset| asset.kind == "ui-prototype") + .find(|asset| asset.kind == GameCreationAppAssetKind::UiDesign) .expect("source asset") .id .clone(); @@ -281,11 +289,9 @@ mod tests { }; let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge"); assert!(first.created); - assert_eq!(first.asset.kind, "UI"); + assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND); // 写侧 → 分类的端到端断言:这条路径走的是与 - // `workflow.rs` / `persistence.rs` 完全相同的 `register_local_asset_at(..., "UI", ...)`。 - // 别名表漏掉大写 `UI` 时,这里会落 unclassified(真机 8 条 UI 资产的表现), - // 且派生值本身就是 unclassified,读时自愈也救不回来。 + // 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量。 assert_eq!( first.asset.category, GameCreationAppAssetCategory::UiInteraction @@ -322,7 +328,10 @@ mod tests { .manifest .assets .iter() - .filter(|asset| asset.kind == "UI") + .filter(|asset| { + asset.kind == UI_DESIGN_DOC_ASSET_KIND + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }) .count(), 1 ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs index 3c7d9a4f1..e827ea75d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs @@ -7,6 +7,7 @@ use crate::ui_editor::layout::node::{Node, StageStatus}; use crate::ui_editor::persistence::{ initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at, LoadUiDesignStateInput, SaveUiDesignStateInput, SaveUiDesignStateResult, + UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE, }; use crate::ui_editor::resource::font::FontAsset; use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder}; @@ -19,6 +20,7 @@ use image::GenericImageView as _; use nalgebra::Vector2; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; +use shared_contracts::game_creation_app::GameCreationAppAssetKind; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::Path; @@ -533,8 +535,8 @@ fn validate_source_asset( root: &Path, asset: &GameCreationAppAssetManifestEntry, ) -> Result<(), String> { - if asset.kind != "ui-prototype" || !asset.media_type.starts_with("image/") { - return Err("ui.workflow.run sourceAssetId 必须是已登记的 ui-prototype 图片".to_string()); + if asset.kind != GameCreationAppAssetKind::UiDesign || !asset.media_type.starts_with("image/") { + return Err("ui.workflow.run sourceAssetId 必须是已登记的 ui-design 图片".to_string()); } validate_image_asset_file(root, asset, "UI 原型图") } @@ -683,8 +685,8 @@ fn find_page_ui_resource( let Some(asset) = matches.into_iter().next() else { return Ok(None); }; - if asset.kind != "UI" - || asset.media_type != "application/json" + if asset.kind != UI_DESIGN_DOC_ASSET_KIND + || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE || asset.local_path != workflow_relative_path(source, &page.page_id) { return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id)); @@ -748,8 +750,8 @@ fn ensure_page_ui_resource( let registered = register_local_asset_at( root, &relative_path, - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + UI_DESIGN_DOC_MEDIA_TYPE, "ui-workflow", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -852,6 +854,21 @@ fn ensure_page_ui_resource( Ok(ui_asset) } +/// 比较 sprite 资源身份时忽略 `metadata.asset_type`。 +/// +/// `asset_type` 是随 canonical kind 派生的展示串,不构成资源身份;身份判定只看真正影响 +/// 渲染的字段,避免展示字段变化造成资源冲突。 +fn same_sprite_asset_identity(current: &SpriteAsset, next: &SpriteAsset) -> bool { + if current.metadata.asset_type == next.metadata.asset_type { + return current == next; + } + let mut current = current.clone(); + let mut next = next.clone(); + current.metadata.asset_type = String::new(); + next.metadata.asset_type = String::new(); + current == next +} + fn install_page_component_assets( root: &Path, state: &mut crate::ui_editor::state::State, @@ -879,11 +896,11 @@ fn install_page_component_assets( .and_then(|name| name.to_str()) .unwrap_or("UI 素材") .to_string(), - asset_type: asset.kind.clone(), + asset_type: asset.kind.to_string(), }; sprite.path = asset.local_path.clone(); match state.sprite_assets.get(&asset_id) { - Some(current) if current != &sprite => { + Some(current) if !same_sprite_asset_identity(current, &sprite) => { return Err(format!( "UI 独立图片/图标 {} 与已有 State 资源冲突", asset.id @@ -1179,7 +1196,7 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul .iter_mut() .find(|asset| asset.id == asset_id) .ok_or_else(|| format!("UI workflow 资源 {} 未登记", asset_id))?; - if asset.kind != "UI" || asset.media_type != "application/json" { + if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE { return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id)); } let next_kind = format!("ui-workflow.{stage}"); @@ -1462,7 +1479,7 @@ mod tests { fn fixture_asset( root: &Path, relative_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, resource_id: &str, ) -> GameCreationAppAssetManifestEntry { register_local_asset_at( @@ -1605,8 +1622,18 @@ mod tests { let design_path = root.join("assets/home.png"); fixture_png(&source_path); fixture_png(&design_path); - let source = fixture_asset(root, "assets/ui-prototype.png", "ui-prototype", "source-ui"); - let design = fixture_asset(root, "assets/home.png", "ui-design", "design-home"); + let source = fixture_asset( + root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + "source-ui", + ); + let design = fixture_asset( + root, + "assets/home.png", + GameCreationAppAssetKind::UiDesign, + "design-home", + ); let page = |operation| UiWorkflowRunInput { operation, source_asset_id: source.id.clone(), @@ -1656,9 +1683,24 @@ mod tests { fixture_png(&root.join("assets/ui-prototype.png")); fixture_png(&root.join("assets/home.png")); fixture_png(&root.join("assets/start-button.png")); - let source = fixture_asset(root, "assets/ui-prototype.png", "ui-prototype", "source-ui"); - let design = fixture_asset(root, "assets/home.png", "ui-design", "design-home"); - let sprite = fixture_asset(root, "assets/start-button.png", "ui-icon", "start-button"); + let source = fixture_asset( + root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + "source-ui", + ); + let design = fixture_asset( + root, + "assets/home.png", + GameCreationAppAssetKind::UiDesign, + "design-home", + ); + let sprite = fixture_asset( + root, + "assets/start-button.png", + GameCreationAppAssetKind::Icon, + "start-button", + ); let font_path = root.join("assets/ui-font.ttf"); fs::copy( Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../public/fusion-pixel.ttf"), @@ -1668,7 +1710,7 @@ mod tests { let font = register_local_asset_at( root, "assets/ui-font.ttf", - "font", + GameCreationAppAssetKind::Font, "font/ttf", "workflow-test", GameCreationAppAssetSource { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 67e17a899..baecf0ed7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -18,12 +18,14 @@ import { type GameCreationAgentCapabilityDescriptor, type GameCreationAgentRunTrace, type GameCreationAgentToolCallTrace, + type GameCreationAppAssetKind, type GameCreationAppCommandDescriptor, type GameCreationAppLimitedRunCommandDescriptor, type GameCreationAppManifest, type GameCreationAppPermission, type GameCreationAppPreviewState, type GameCreationAppPreviewStatus, + parseGameCreationAppAssetKind, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD, @@ -292,7 +294,7 @@ import { } from './services/platformSession'; import { setAgcPluginProjectPath, - startAvailableAgcPlugin, + startAvailableAgcEditorPlugins, } from './services/pluginHost'; import { canSubscribeTauriEvents, @@ -595,29 +597,31 @@ export function App({ localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 if (!nextProjectPath && !previousProjectPath) return; + let active = true; void setAgcPluginProjectPath(nextProjectPath) .then(async () => { - if (workspaceProjectKind === 'cocos' && nextProjectPath) { - await startAvailableAgcPlugin('agc-cocos-editor'); + if (active && nextProjectPath) { + await startAvailableAgcEditorPlugins(() => active); } }) .catch((error) => { - if (workspaceProjectKind !== 'cocos' || !nextProjectPath) { + if (!active || !nextProjectPath) { return; } setWorkspaceStatus( - `Cocos Creator 插件未就绪:${ + `编辑器插件未就绪:${ error instanceof Error ? error.message : String(error) }`, ); }); return () => { + active = false; if (nextProjectPath || previousProjectPath) { void setAgcPluginProjectPath(null).catch(() => undefined); } localProjectPathRef.current = null; }; - }, [localProject?.projectPath, supervisorChatOnly, workspaceProjectKind]); + }, [localProject?.projectPath, supervisorChatOnly]); const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( @@ -1038,7 +1042,7 @@ export function App({ UploadLocalAssetResult[] >([]); const [assetLocalPath, setAssetLocalPath] = useState('assets/hero.png'); - const [assetKind, setAssetKind] = useState('asset'); + const [assetKind, setAssetKind] = useState('unknown'); const [assetMediaType, setAssetMediaType] = useState( 'application/octet-stream', ); @@ -1448,9 +1452,7 @@ export function App({ void openWorkspace( initialProjectPath, false, - initialProjectKind === 'godot' || initialProjectKind === 'cocos' - ? 'open' - : 'create', + initialProjectKind !== 'web' ? 'open' : 'create', initialProjectKind, ); // Initial project opening is guarded by initialProjectOpenedRef. @@ -5201,7 +5203,7 @@ export function App({ } const [ localPath, - kind = 'asset', + kind = 'unknown', mediaType = 'application/octet-stream', ] = prompt.slice('/asset-register '.length).trim().split(/\s+/); if (!localPath) { @@ -5221,7 +5223,12 @@ export function App({ ]); return; } - queuePendingCommand({ id: 'asset.register', localPath, kind, mediaType }); + queuePendingCommand({ + id: 'asset.register', + localPath, + kind: parseGameCreationAppAssetKind(kind, 'chat.asset-register.kind'), + mediaType, + }); setMessages((current) => [ ...current, { role: 'assistant', text: `准备登记项目资产:${localPath}` }, @@ -5677,7 +5684,10 @@ export function App({ canvasProjectId, canvasAssetId: canvasAssetObjectId ? '' : canvasAssetId, canvasAssetObjectId, - kind: kind ?? 'asset', + kind: parseGameCreationAppAssetKind( + kind ?? 'unknown', + 'chat.canvas-asset-import.kind', + ), mediaType: mediaType ?? 'application/octet-stream', }); setMessages((current) => [ @@ -9803,7 +9813,7 @@ export function App({ void executeAssetRegister( nextProjectPath, localPath, - assetKind, + parseGameCreationAppAssetKind(assetKind, 'ui.asset-register.kind'), assetMediaType, assetSourceKind, canvasProjectId, @@ -9816,7 +9826,7 @@ export function App({ async function executeAssetRegister( nextProjectPath: string, localPath: string, - kind: string, + kind: GameCreationAppAssetKind, mediaType: string, sourceKind: string, canvasProjectId: string, @@ -9943,7 +9953,10 @@ export function App({ void executeCanvasAssetImport( nextProjectPath, localPath, - assetKind, + parseGameCreationAppAssetKind( + assetKind, + 'ui.canvas-asset-import.kind', + ), assetMediaType, canvasProjectId, resourceId, @@ -9956,7 +9969,7 @@ export function App({ async function executeCanvasAssetImport( nextProjectPath: string | null, localPath: string, - kind: string, + kind: GameCreationAppAssetKind, mediaType: string, canvasProjectId: string, resourceId: string, diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index fdef7af6f..2e9aeeda0 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -23,14 +23,7 @@ import { normalizeAuthPhoneInput, sendClientPhoneLoginCode, } from '../services/clientAuth'; -import { - type ClientServerPreset, - type ClientServerSelection, - getClientServerBaseUrl, - getClientServerSelection, - normalizeClientServerBaseUrl, - setClientServerSelection, -} from '../services/clientHttp'; +import { getClientServerBaseUrl } from '../services/clientHttp'; import { captureClientError, installWebviewLogBridge, @@ -158,13 +151,6 @@ export function AuthenticatedClient({ const [loginBusy, setLoginBusy] = useState(false); const [codeBusy, setCodeBusy] = useState(false); const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0); - const initialServerSelection = getClientServerSelection(); - const [serverSelection, setServerSelection] = useState( - initialServerSelection, - ); - const [customServerUrl, setCustomServerUrl] = useState( - initialServerSelection.customBaseUrl, - ); useEffect(() => { const uninstallWebviewLogBridge = installWebviewLogBridge(); const handleError = (event: ErrorEvent) => { @@ -184,37 +170,6 @@ export function AuthenticatedClient({ }; }, []); - function persistServerSelection() { - try { - const next = setClientServerSelection({ - preset: serverSelection.preset, - customBaseUrl: customServerUrl, - }); - setServerSelection(next); - return next; - } catch (error) { - void captureClientError(error, { - source: 'auth-hydrate', - action: 'restore-session', - }); - setLoginStatus(error instanceof Error ? error.message : String(error)); - return null; - } - } - - function handleServerPresetChange(preset: ClientServerPreset) { - if (preset === 'custom') { - setServerSelection((current) => ({ ...current, preset })); - return; - } - const next = setClientServerSelection({ - preset, - customBaseUrl: customServerUrl, - }); - setServerSelection(next); - setLoginStatus(`已选择 ${preset} 服务器`); - } - useEffect(() => { let disposed = false; async function hydrateAuth() { @@ -430,11 +385,7 @@ export function AuthenticatedClient({ if (codeBusy || codeCooldownSeconds > 0) { return; } - const persistedSelection = persistServerSelection(); - if (!persistedSelection) { - return; - } - const apiBaseUrl = getClientServerBaseUrl(persistedSelection); + const apiBaseUrl = getClientServerBaseUrl(); const normalizedPhone = normalizeAuthPhoneInput(phone); if (!normalizedPhone) { setLoginStatus('请输入手机号'); @@ -479,11 +430,7 @@ export function AuthenticatedClient({ setLoginStatus('请输入密码'); return; } - const persistedSelection = persistServerSelection(); - if (!persistedSelection) { - return; - } - const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection); + const loginApiBaseUrl = getClientServerBaseUrl(); const loginAttempt = (loginAttemptRef.current += 1); setLoginBusy(true); setLoginStatus('正在登录'); @@ -635,50 +582,6 @@ export function AuthenticatedClient({ ) : null} - - {serverSelection.preset === 'custom' ? ( - - ) : null}
- - - + {templateLibraryEnabled ? ( +
+
+ +

+ 模板库 +

+
+ +
+ +
+ ) : null}
); } diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx index 4464661ce..013e12e0c 100644 --- a/apps/ai-game-creator-shell/src/view/layout.tsx +++ b/apps/ai-game-creator-shell/src/view/layout.tsx @@ -42,6 +42,7 @@ type SidebarUserInfo = { type LauncherSidebarProps = { activeView: LauncherView; + templateLibraryEnabled: boolean; currentUser: SidebarUserInfo; onViewChange: (view: LauncherView) => void; onNoticeRequest: (title: string) => void; @@ -193,6 +194,7 @@ function SidebarAccountMenu({ export function Sidebar({ activeView, + templateLibraryEnabled, currentUser, onViewChange, onNoticeRequest, @@ -286,19 +288,21 @@ export function Sidebar({ >