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 3f569063f..985b536ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1635,27 +1635,34 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result { normalize_relative_path(&normalized) } +/// 画板导出图层推断出的资源 kind。 +/// +/// 返回值必须是 **canonical kind**:这个值会被 `register_local_asset_entry` 原样写入 +/// manifest,并据以派生落盘 `category`。历史上这里写过非 canonical 的 +/// `ui` / `animation` / `asset`,只能靠别名表兜底,等于同时维护两套词汇;别名表只用于 +/// 兼容存量数据,不作为新写入值的来源。 pub(crate) fn infer_canvas_export_asset_kind( layer: &CanvasExportLayerMetadata, file: &str, ) -> &'static str { let layer_type = layer.visible.layer_type.as_str(); - if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) { - return "animation"; - } - if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) { - return "audio"; - } - if contains_any(layer_type, &["角色"]) { - return "character"; - } - if contains_any(layer_type, &["场景", "背景"]) { - return "scene"; - } - if contains_any(layer_type, &["UI", "界面", "图标"]) { - return "ui"; - } - "asset" + let inferred = if file.starts_with("sequences/") + || contains_any(layer_type, &["序列", "动画", "动作"]) + { + "character-animation" + } else if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) { + "audio" + } else if contains_any(layer_type, &["角色"]) { + "character" + } else if contains_any(layer_type, &["场景", "背景"]) { + "scene" + } else if contains_any(layer_type, &["UI", "界面", "图标"]) { + "ui-design" + } else { + "image" + }; + // 防御性归一:分支字面量写错时由覆盖测试暴露,这里再兜一层。 + shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(inferred) } pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str { @@ -1975,6 +1982,67 @@ mod tests { use super::*; use std::io::{Read, Write}; + /// 画板导出推断出的 kind 必须已经是 canonical 值。 + /// + /// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值 + /// (历史上曾写 `ui` / `animation` / `asset`),就只能靠别名表兜底,等于同时维护 + /// 两套词汇,且已落盘的 category 无法随别名修复自愈。这里逐分支钉死。 + #[test] + fn canvas_export_asset_kind_is_always_canonical() { + fn layer(layer_type: &str) -> CanvasExportLayerMetadata { + serde_json::from_value(serde_json::json!({ + "title": "测试图层", + "file": "layer.png", + "exportError": null, + "visible": { + "type": layer_type, + "model": "-", + "task": "-", + "object": "-", + }, + })) + .expect("canvas export layer metadata") + } + + // 每个分支的代表输入 → 期望的 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"), + // 无匹配时落到 image。 + ]; + for (layer_type, file, expected) in cases { + let kind = infer_canvas_export_asset_kind(&layer(layer_type), file); + assert_eq!(kind, expected, "layer_type={layer_type} file={file}"); + } + // media/ 前缀同样走音频分支。 + assert_eq!( + infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"), + "audio" + ); + // 兜底分支是 image 而不是旧的 "asset"。 + assert_eq!( + infer_canvas_export_asset_kind(&layer("图层"), "layer.png"), + "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), + "非 canonical kind: {kind}(layer_type={layer_type})" + ); + for legacy in ["ui", "animation", "asset"] { + assert_ne!(kind, legacy, "写回了非 canonical 的 {legacy}"); + } + } + } + #[test] fn infer_file_extension_preserves_supported_local_resource_extensions() { assert_eq!( diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts index cd7b33b06..9b4ed49b4 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts @@ -118,63 +118,59 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { expect(resources).toHaveLength(61); }); - test('存量 category 与新 kind 口径的差异必须显式记录(别名救不了存量)', () => { + test('存量 57 条 ui 的落盘 unclassified 被读时重派生自愈', () => { const assets = realManifest().assets; - const drifted = assets - .map((asset) => ({ - kind: asset.kind, - onDisk: asset.category, - // 只按 kind 重新派生(丢弃存量 category),这就是加别名之后新登记资产会拿到的值。 - derivedFromKind: gameCreationAppAssetCategory({ - kind: asset.kind, - category: undefined, - }), - })) - .filter((entry) => entry.onDisk !== entry.derivedFromKind); + const uiAssets = assets.filter((asset) => asset.kind === 'ui'); - const driftedByKind = new Map< - string, - { onDisk: string; derivedFromKind: string; count: number } - >(); - for (const entry of drifted) { - const key = `${entry.kind}|${entry.onDisk}|${entry.derivedFromKind}`; - const existing = driftedByKind.get(key); - if (existing) { - existing.count += 1; - } else { - driftedByKind.set(key, { ...entry, count: 1 }); - } - } + // 前置事实:这 57 条落盘值确实是 unclassified(历史误判现场)。 + expect(uiAssets).toHaveLength(57); + expect(uiAssets.every((asset) => asset.category === 'unclassified')).toBe( + true, + ); + // 读取侧已经把它重派生成 ui-interaction:读时自愈,不回写 manifest。 + expect( + uiAssets.every( + (asset) => gameCreationAppAssetCategory(asset) === 'ui-interaction', + ), + ).toBe(true); - // 真机现状:57 条 ui 的落盘 category 是 unclassified,而按新 kind 口径应是 - // ui-interaction。这条差异是「别名只救新登记、不救存量」的直接证据:读取侧 - // `gameCreationAppAssetCategory` 优先信任落盘 category,所以别名加完, - // 这 57 条仍然留在「待归类」。 - expect(drifted).toHaveLength(57); - expect(Object.fromEntries(driftedByKind)).toEqual({ - 'ui|unclassified|ui-interaction': { - kind: 'ui', - onDisk: 'unclassified', - derivedFromKind: 'ui-interaction', - count: 57, - }, - }); + // 直接钉住「读时重派生」这一行为本身:落盘 unclassified + kind 能派生明确分类 + // → 用派生值;这正是修复前那 57 条留在「待归类」的原因。 + expect( + gameCreationAppAssetCategory({ kind: 'ui', category: 'unclassified' }), + ).toBe('ui-interaction'); + // 落盘值本身就是明确分类时仍然信任落盘值(用户手动设置的权威性)。 + expect( + gameCreationAppAssetCategory({ kind: 'ui', category: 'scene' }), + ).toBe('scene'); + // 落盘值缺失时按 kind 派生(历史 manifest 兼容)。 + expect( + gameCreationAppAssetCategory({ kind: 'ui', category: undefined }), + ).toBe('ui-interaction'); }); - test('别名落地后存量仍落待归类:58 不变(根治需要读时重派生或一次性回填)', () => { + test('落盘 unclassified 且 kind 派生也是 unclassified 时不受影响', () => { + // image / video / code 的 canonical 分类本来就是 unclassified, + // 这类资产必须继续信任落盘值,不被重派生规则误伤。 + for (const kind of ['image', 'video', 'code', 'publication-material']) { + expect( + gameCreationAppAssetCategory({ kind, category: 'unclassified' }), + ).toBe('unclassified'); + } + }); + + test('读时重派生后:57 条 ui 归位 UI 交互,待归类只剩 code 类 game-entry', () => { const resources = projectResourcesFromReadModels(realManifest(), [], []); const byCategory = countBy(resources, (resource) => projectResourceCanvasCategory(resource), ); - // 期望分布是加别名**之后**的实际结果,不是目标态。 + // 存量自愈后的真实分布:57 条 ui 归位到 UI 交互,只剩 game-entry(code)在待归类。 expect(byCategory).toEqual({ - unclassified: 58, - 'ui-interaction': 2, + unclassified: 1, + 'ui-interaction': 59, scene: 1, }); - // 若哪天真做了存量回填,这两个数字应当变成 unclassified:1 / ui-interaction:59, - // 届时这条断言会失败并提醒更新口径。 const uiResources = resources.filter( (resource) => resource.subtype === 'ui', ); @@ -182,9 +178,18 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { expect( uiResources.every( (resource) => - projectResourceCanvasCategory(resource) === 'unclassified', + projectResourceCanvasCategory(resource) === 'ui-interaction', ), ).toBe(true); + // 唯一留在待归类的就是 game-entry:code → unclassified 是分类表的设计口径。 + expect( + resources + .filter( + (resource) => + projectResourceCanvasCategory(resource) === 'unclassified', + ) + .map((resource) => resource.subtype), + ).toEqual(['game-entry']); }); test('按预览分支计数:只有 JSON 规格会落占位,52 张 PNG 走图片分支', () => { @@ -213,15 +218,15 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { expect(countBy(placeholders, (e) => e.resource.subtype)).toEqual({ ui: 8 }); }); - test('按画布栏目计数:58 条落待归类,与真机截图一致', () => { + test('按画布栏目计数:待归类从 58 降到 1,UI 交互升到 59', () => { const resources = projectResourcesFromReadModels(realManifest(), [], []); const byCategory = countBy(resources, (resource) => projectResourceCanvasCategory(resource), ); expect(byCategory).toEqual({ - unclassified: 58, - 'ui-interaction': 2, + unclassified: 1, + 'ui-interaction': 59, scene: 1, }); }); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index dcd4f8939..82223966f 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -357,6 +357,8 @@ type UpdateProjectResourceCanvasLayoutResult = 实现状态(2026-09-10):资源画布分区口径是 manifest 资产的功能分类 `category`(`ui-interaction / character / scene / audio / document / unclassified`)加末尾独立的「项目版本」栏目,不再按扩展名或 mediaType 派生分区。`icon / icon-spritesheet / icon-spec / ui-design` 进入 UI 交互,`character / character-animation` 进入角色与对象,`scene` 进入场景与环境,`sound-effect / background-music / audio` 进入音频,`spec` 与合法 Agent 文本回执进入文档;`image / video / code / publication-material` 以及任务产物、导入附件进入待归类,只登记游戏代码的项目因此有可见栏目与卡片,不再出现四栏全空;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本。扩展名分类器只保留准入与卡片显示类型职责:无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。既有布局 sidecar 的旧栏目坐标按读时归并继续生效,`x / y / manuallyPlaced` 原样保留。 +分类取值优先级(2026-09-10 收口):落盘 `category` 是权威值(用户可在「分类与标签」面板手动设置),缺失或非法时按 `assets[].kind` 派生。唯一例外是读时自愈——落盘值为 `unclassified` 而该资产 `kind` 能派生出明确的非 `unclassified` 分类时采用派生值,用于修复历史上被系统误写成 `unclassified` 的存量数据(无需迁移脚本、永久自愈);`kind` 派生结果本身就是 `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。该例外的已知盲区是「用户手动把 kind 已能明确分类的资产设为待归类」会被覆盖,属有意接受的最小覆盖窗口。写入侧必须只产出 canonical kind(画板导出推断同样如此),别名表仅用于兼容存量数据。 + 资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。 ```ts diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e10a375f2..20ee5052b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5163,3 +5163,13 @@ - 另一处必须成对维护:别名表有**两份实现**——TS 侧 `packages/shared/src/contracts/gameCreationApp.ts` 的 `GAME_CREATION_APP_LEGACY_ASSET_KINDS`(读投影用)与 Rust 侧 `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的 `canonical_game_creation_app_asset_kind`(写入侧按 kind 派生 category 用)。只改一边就会让落盘 category 与读侧栏目互相矛盾。交叉守卫见 `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts`(直接解析 Rust 源码比对)。 - 真机计数守卫见 `apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts`。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/assets.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`、`packages/shared/src/contracts/gameCreationApp.ts`、`server-rs/crates/shared-contracts/src/game_creation_app.rs`。 + +## 读时重派生:为什么可以覆盖落盘 category,以及它的唯一盲区(2026-09-10) + +- 规则(已拍板落地):`gameCreationAppAssetCategory` 在「落盘 `category === 'unclassified'` **且** 该资产 `kind` 能派生出明确的非 `unclassified` 分类」时采用派生值,其余情况信任落盘值。 +- 为什么需要这条:`register_local_asset_entry`(`assets.rs`)命中同 `localPath` 的既有资产时只覆盖 `kind` / `media_type` / `source`,**不重算 `category`**;于是历史上被写成 `unclassified` 的资产(典型是 `kind:"ui"` 因不在 canonical 目录而落到 `image → unclassified`)在补齐别名后**不会自愈**。选读时重派生而不是写迁移脚本:不需要迁移、且永久自愈(任何历史上被系统错判成 unclassified 的都会自动归位)。真机验证:`What do u wanna do kitten` 的待归类从 58 降到 1(只剩 code 类 `game-entry`),UI 交互从 2 升到 59。 +- 为什么可以覆盖落盘值:落盘 `category` 的权威性来自「用户可在分类与标签面板手动设置」(`update_manifest_asset_classification_at`,`project/manifest.rs`)。收窄条件把覆盖窗口压到最小——只有当落盘值是 `unclassified`(即"没有明确分类")时才覆盖。 +- **唯一盲区**:用户**手动**把一个 kind 已能明确分类的资产设成「待归类」时,该手动值会被覆盖。这是有意接受的取舍:「手动设为待归类」意图边缘,且 kind 已经表达了分类;而漏掉这条规则,所有历史误判都无法自愈。若将来产品需要"显式待归类",应改成在 manifest 里区分"未设置"与"显式 unclassified"(例如 `category` 缺省 vs 显式写入),而不是取消本条规则。 +- 不受影响:`image` / `video` / `code` / `publication-material` 的 canonical 分类本身就是 `unclassified`,派生结果等于落盘值,规则不触发(已用断言钉住)。 +- 写入侧已同步 canonical 化:`infer_canvas_export_asset_kind`(`assets.rs`)现在直接产出 canonical kind(`ui → ui-design`、`animation → character-animation`、`asset → image`),不再依赖别名表兜底;`canvas_export_asset_kind_is_always_canonical` 用例逐分支钉死,别名表从此只承担存量兼容。 +- 关联:`packages/shared/src/contracts/gameCreationApp.ts` 的 `gameCreationAppAssetCategory`、`apps/ai-game-creator-shell/src-tauri/src/assets.rs`、`apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts`。 diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 66ef93e39..0077d4031 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -604,14 +604,35 @@ export function gameCreationAppAssetCategoryForKind( ]; } -/** 历史 manifest 缺少 category 时按 kind 派生;未知分类值同样退回 kind 派生。 */ +/** + * manifest 资产的最终功能分类。 + * + * 优先级:落盘 `category`(用户可在「分类与标签」面板手动设置,是权威值)→ 按 `kind` 派生。 + * 历史 manifest 缺少 category、或 category 非法时都退回 kind 派生。 + * + * **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的 + * 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成 + * unclassified」的存量数据——典型例子是 `kind:"ui"` 的 UI 资产曾因 kind 不在 canonical + * 目录而落到 `image → unclassified`,修复别名后并不会自动归位,因为落盘值已固化。 + * + * 盲区:用户**手动**把一个 kind 已能明确分类的资产设成「待归类」时,该手动值会被这条 + * 规则覆盖。这是有意接受的取舍——「手动设为待归类」本身意图边缘,且 kind 已经表达了分类; + * 而反过来漏掉这条规则,所有历史误判都无法自愈。 + * kind 派生结果本身就是 `unclassified` 的(如 `image` / `video` / `code`)不受影响, + * 仍然信任落盘值。 + */ export function gameCreationAppAssetCategory( asset: Pick, ): GameCreationAppAssetCategory { - return ( - normalizeGameCreationAppAssetCategory(asset.category) ?? - gameCreationAppAssetCategoryForKind(asset.kind) - ); + const persisted = normalizeGameCreationAppAssetCategory(asset.category); + const derivedFromKind = gameCreationAppAssetCategoryForKind(asset.kind); + if (persisted === null) { + return derivedFromKind; + } + if (persisted === 'unclassified' && derivedFromKind !== 'unclassified') { + return derivedFromKind; + } + return persisted; } export function gameCreationAppAssetTags(