diff --git a/apps/ai-game-creator-shell/src/contracts/assetKind.ts b/apps/ai-game-creator-shell/src/contracts/assetKind.ts index c4ec744e4..58ce50634 100644 --- a/apps/ai-game-creator-shell/src/contracts/assetKind.ts +++ b/apps/ai-game-creator-shell/src/contracts/assetKind.ts @@ -2,36 +2,54 @@ import type { GameCreationAppAssetKind } from './generated/GameCreationAppAssetK export type { GameCreationAppAssetKind } from './generated/GameCreationAppAssetKind'; -export const GAME_CREATION_APP_ASSET_KINDS = [ - 'image', - 'scene', - 'character', - 'character-animation', - 'icon', - 'icon-spritesheet', - 'icon-spec', - 'ui-design', - 'ui-design-doc', - 'publication-material', - 'spec', - 'video', - 'audio', - 'sound-effect', - 'background-music', - 'font', - 'document', - 'code', - 'unknown', -] as const satisfies readonly GameCreationAppAssetKind[]; +/** + * 穷举生成的 union:这里多写或少写一个成员都会直接报类型错误, + * 避免再维护一份与 `GameCreationAppAssetKind.ts` 分叉的手写列表。 + */ +const GAME_CREATION_APP_ASSET_KIND_MEMBERS: Record< + GameCreationAppAssetKind, + true +> = { + image: true, + scene: true, + character: true, + 'character-animation': true, + icon: true, + 'icon-spritesheet': true, + 'icon-spec': true, + 'ui-design': true, + 'ui-design-doc': true, + 'publication-material': true, + spec: true, + video: true, + audio: true, + 'sound-effect': true, + 'background-music': true, + font: true, + document: true, + code: true, + unknown: true, +}; + +export const GAME_CREATION_APP_ASSET_KINDS: readonly GameCreationAppAssetKind[] = + Object.keys( + GAME_CREATION_APP_ASSET_KIND_MEMBERS, + ) as GameCreationAppAssetKind[]; const KNOWN_ASSET_KINDS = new Set(GAME_CREATION_APP_ASSET_KINDS); +export function isGameCreationAppAssetKind( + raw: unknown, +): raw is GameCreationAppAssetKind { + return typeof raw === 'string' && KNOWN_ASSET_KINDS.has(raw); +} + export function parseGameCreationAppAssetKind( raw: unknown, context: string, ): GameCreationAppAssetKind { - if (typeof raw === 'string' && KNOWN_ASSET_KINDS.has(raw)) { - return raw as GameCreationAppAssetKind; + if (isGameCreationAppAssetKind(raw)) { + return raw; } const rawValue = typeof raw === 'string' ? raw : String(raw); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 790552ead..41cc55ff4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -96,7 +96,10 @@ import { isFloatingOverlayWheelEvent, useImageCanvasFloatingOptionDismiss, } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; -import { parseGameCreationAppAssetKind } from '../../contracts/assetKind'; +import { + isGameCreationAppAssetKind, + parseGameCreationAppAssetKind, +} from '../../contracts/assetKind'; import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel'; import { LocalGamePreviewFrame, @@ -6096,7 +6099,11 @@ export default function ProjectDevelopmentView({ sourceResourceId: resource.id, sourcePath: resource.path, sourceMediaType: canonicalProjectedResourceMediaType(resource), - sourceSubtype: resource.subtype, + // sourceSubtype 只接受 manifest 的正式 kind;任务产物等非 manifest + // 来源不通过该字段传递。 + sourceSubtype: isGameCreationAppAssetKind(resource.subtype) + ? resource.subtype + : null, producerTaskId: resource.producerTaskId ?? '', }, }), diff --git a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts index 12d9fc4c0..7584ff535 100644 --- a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts +++ b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts @@ -44,7 +44,9 @@ function rustAssetKindAliases(): Map { /** 从 Rust 侧解析 canonical kind 常量目录。 */ function rustCanonicalKinds(): string[] { - const source = readFileSync(RUST_CONTRACT_PATH, 'utf8'); + const source = expandRustAssetKindConsts( + readFileSync(RUST_CONTRACT_PATH, 'utf8'), + ); const start = source.indexOf( 'pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS', ); @@ -53,6 +55,27 @@ function rustCanonicalKinds(): string[] { return Array.from(body.matchAll(/"([^"]+)"/g)).map((match) => match[1]!); } +/** + * 把 Rust 侧 `pub const ..._ASSET_KIND: &str = "..."` 的常量引用展开成字符串字面量。 + * + * 共享契约里 `ui-design-doc` 等 kind 是常量(如 + * `GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND`)而不是裸字面量,不展开时下面的正则解析会 + * 漏掉这些条目,两侧目录比对就会假红。 + */ +function expandRustAssetKindConsts(source: string): string { + const consts = new Map(); + for (const decl of source.matchAll( + /pub const (\w*ASSET_KIND\w*)\s*:\s*&str\s*=\s*"([^"]+)";/g, + )) { + consts.set(decl[1]!, decl[2]!); + } + let expanded = source; + for (const [name, value] of consts) { + expanded = expanded.replaceAll(name, `"${value}"`); + } + return expanded; +} + /** Rust 的 `GameCreationAppAssetCategory` 变体名 → kebab-case 线上值。 */ function rustCategorySocketName(variant: string) { return variant.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); @@ -60,7 +83,9 @@ function rustCategorySocketName(variant: string) { /** 从 Rust 侧 `GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 里解析出 kind → 栏目表。 */ function rustAssetCategoryByKind(): Map { - const source = readFileSync(RUST_CONTRACT_PATH, 'utf8'); + const source = expandRustAssetKindConsts( + readFileSync(RUST_CONTRACT_PATH, 'utf8'), + ); const start = source.indexOf( 'pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND', ); @@ -120,10 +145,9 @@ const OBSERVED_ALIAS_INPUTS = [ describe('资源 kind 别名表跨语言一致性', () => { test('两侧 canonical kind 目录一致', () => { - 'font', - expect(rustCanonicalKinds()).toEqual([ - ...GAME_CREATION_APP_CANONICAL_ASSET_KINDS, - ]); + expect(rustCanonicalKinds()).toEqual([ + ...GAME_CREATION_APP_CANONICAL_ASSET_KINDS, + ]); }); test('两侧别名映射逐条一致', () => { @@ -311,9 +335,9 @@ describe('写侧 kind 字面量 → 分类的端到端口径', () => { 'apps/ai-game-creator-shell/src-tauri/src/commands.rs', 'utf8', ); - // 字体上传的写入侧:`register_local_asset_entry(root, path, "font", …)`。 + // 字体上传的写入侧直接传正式枚举成员:`register_local_asset_entry(root, path, GameCreationAppAssetKind::Font, …)`。 expect(commandsSource).toMatch( - /register_local_asset_entry\(\s*\n\s*root,\s*\n\s*&relative_path,\s*\n\s*"font",/, + /register_local_asset_entry\(\s*\n\s*root,\s*\n\s*&relative_path,\s*\n\s*GameCreationAppAssetKind::Font,/, ); expect(gameCreationAppAssetCategoryForKind('font')).toBe('document'); });