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 0d52796fd..ee971314c 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 @@ -490,7 +490,10 @@ pub(crate) fn platform_art_asset_manifest_kind(asset_kind: &str) -> GameCreation "art-spritesheet" | "icon-spritesheet" => GameCreationAppAssetKind::IconSpritesheet, "publication-material" => GameCreationAppAssetKind::PublicationMaterial, "game-background" | "scene" => GameCreationAppAssetKind::Scene, - _ => GameCreationAppAssetKind::Unknown, + // 画板/平台词汇表(`AGENT_RUNTIME_CANVAS_ASSET_KINDS`)到 manifest kind 的**唯一** + // 映射点:认不出的原值收口成 `Unknown` 并留痕(回调接到壳层 `app_log!`), + // manifest 侧不再有第二张别名表。 + _ => GameCreationAppAssetKind::parse_with_context(asset_kind, "canvas.asset_kind"), } } 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 74d7c8818..3115963c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -2235,8 +2235,7 @@ mod tests { 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.as_str()), + GameCreationAppAssetKind::ALL.contains(&kind), "非 canonical kind: {kind}(layer_type={layer_type})" ); for legacy in ["ui", "animation", "asset"] { @@ -2276,12 +2275,7 @@ mod tests { 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.as_str()) - || matches!( - kind, - GameCreationAppAssetKind::Font | GameCreationAppAssetKind::Unknown - ), + GameCreationAppAssetKind::ALL.contains(&kind), "非 canonical kind: {kind}({file_name})" ); } 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 04278b797..c2121b56e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -3245,8 +3245,11 @@ mod agent_asset_import_tests { ); } - /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**, - /// 判不出才退回中性 `image`(→ 「待归类」),**永远不许回退成常量 `ui`**。 + /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**只接受 canonical 值**, + /// 认不出的原值收口成 `unknown`(→ 「待归类」)并留痕原始串,**永远不许回退成常量 `ui`**。 + /// + /// kind 没有别名表:平台侧写 `"UI"` / `"ui"` / `"mystery"` 时判据是"这不是合法 kind", + /// 而不是"帮它归一成 `ui-design`";缺失/空白才退回中性的 `image`。 /// /// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回 `"ui"` 的实现, /// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。 @@ -3266,20 +3269,31 @@ mod agent_asset_import_tests { imported_platform_asset_kind(Some("icon-spec")), Kind::IconSpec ); - // 平台确实说它是 UI 设计稿时,才允许落 `ui-design`(→ 「UI 交互」); - // 大写 `UI` 与旧值 `ui` 都要归一到同一口径,但**不允许由我们替它默认**。 - assert_eq!(imported_platform_asset_kind(Some("UI")), Kind::UiDesign); - assert_eq!(imported_platform_asset_kind(Some("ui")), Kind::UiDesign); - // 缺失 / 空白 / 未知值一律落 canonical `image` 兜底(→ 「待归类」)。 + // 平台确实说它是 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); - assert_eq!(imported_platform_asset_kind(Some("mystery")), Kind::Image); - // `kind` 是外部输入,原型链键必须走 `image` 兜底,不能取到原型上的值。 - assert_eq!(imported_platform_asset_kind(Some("__proto__")), Kind::Image); - assert_eq!( - imported_platform_asset_kind(Some("constructor")), - Kind::Image - ); + // 非 canonical 原值(含大小写变体、旧别名与原型链键)一律收口成 `unknown`: + // 判据落在「这不是合法 kind」,原值只出现在留痕日志里。 + for raw in [ + "UI", + "ui", + "mystery", + "game-art", + "ui-prototype", + "__proto__", + "constructor", + ] { + assert_eq!( + imported_platform_asset_kind(Some(raw)), + Kind::Unknown, + "{raw} 不是 canonical kind,不得被归一" + ); + } } /// 反查门禁:两处平台素材导入必须继续用 `imported_platform_asset_kind` 解析 `kind`, @@ -4164,30 +4178,22 @@ pub(crate) fn import_local_project_assets_for_agent( } /// 平台素材导入落盘的 manifest `kind`:**有真实类型就用真实类型**,判不出才退回中性的 -/// `image`(派生 `unclassified` → 「待归类」)。 +/// `image`(派生 `unclassified` → 「待归类」),认不出的原值落 `unknown`(同样「待归类」) +/// 并在日志里留下原始串。 /// /// 这里绝不允许回退成 `ui`:账户素材库记录与网页项目画布响应本来就带 `assetKind` /// (`AgentEditorAssetRecord::asset_kind` / 响应字段 `assetKind`),把它换成常量等于丢掉 /// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏 /// (`ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值, /// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。 +/// +/// kind 只有一条口径:严格解析,不做别名归一、不做迁移(`Kind.ALL` 是唯一词汇表)。 fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind { match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) { - // 平台是外部系统,它的 `assetKind` 词汇(含大写 `UI` 这类变体)由共享契约统一收口到 - // canonical 值;这里只把收口结果转成正式枚举,写入侧不再产出裸字符串。 - Some(kind) => { - let canonical = - shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind); - if canonical != kind { - // 收口会抹掉原值,而 kind 本身是严格解析(不 fallback、不迁移), - // 所以非 canonical 的原值必须在这里留痕:这是后续定位"平台侧谁还在写 - // legacy kind"的唯一线索。 - app_log!( - "平台 assetKind 不是 canonical 值,已按共享契约收口:rawKind={kind} canonicalKind={canonical}" - ); - } - GameCreationAppAssetKind::parse_with_context(canonical, "platform.asset_kind") - } + // 平台是外部系统,但 kind 口径**只有一条**:严格解析 + 非 canonical 原值留痕 + // (回调已由壳层注册成 `app_log!`,见 `main.rs`)。这里不再做别名收口, + // 认不出的值就是 `Unknown` → 「待归类」,原值只出现在日志里。 + Some(kind) => GameCreationAppAssetKind::parse_with_context(kind, "platform.asset_kind"), None => GameCreationAppAssetKind::Image, } } 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 f53ae4637..7e2ca83eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -240,6 +240,36 @@ macro_rules! app_log { }}; } +/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。 +/// +/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, +/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, +/// 以便回查"还有谁在写 legacy kind"。kind 本身只接受严格值,不做别名、不做迁移。 +/// +/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; +/// 去重后"有哪些 legacy 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 +/// `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; @@ -2428,6 +2458,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); 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 c5f912c0b..04349b47a 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 @@ -1951,14 +1951,18 @@ pub(crate) fn read_manifest(path: &Path) -> Result>> = OnceLock::new(); + static LOGGED_OFFENDERS: OnceLock>> = OnceLock::new(); for (raw_kind, asset_id, local_path) in non_canonical_manifest_asset_kinds(payload) { - let logged = LOGGED_RAW_KINDS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); + let logged = LOGGED_OFFENDERS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); let first_seen = logged .lock() - .map(|mut logged| logged.insert(raw_kind.clone())) + .map(|mut logged| { + logged.insert(format!("{}\u{1}{asset_id}\u{1}{local_path}", raw_kind)) + }) .unwrap_or(true); if !first_seen { continue; @@ -1985,7 +1989,7 @@ fn non_canonical_manifest_asset_kinds(payload: &str) -> Vec<(String, String, Str continue; }; if raw_kind.trim() == GameCreationAppAssetKind::Unknown.as_str() - || GameCreationAppAssetKind::from_str_lossy(raw_kind) + || GameCreationAppAssetKind::from_str_or_unknown(raw_kind) != GameCreationAppAssetKind::Unknown { continue; 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..b6802ed0f 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,7 +207,8 @@ fn version_resource_size_spec_equal( /// 三项兼容性判据(后端是权威判据,前端只做呈现)。 /// /// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口); -/// - `subtypeEqual`:canonical `kind` 相等(别名表在 `shared-contracts`)——以上两项是硬门禁; +/// - `subtypeEqual`:`kind` 严格相等(kind 没有别名表,非 canonical 原值一律收口成 +/// `unknown`)——以上两项是硬门禁; /// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`],只作提示。 fn version_resource_compatibility( source: &GameCreationAppAssetManifestEntry, @@ -217,8 +217,7 @@ fn version_resource_compatibility( 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), + subtype_equal: source.kind == replacement.kind, size_spec_equal: version_resource_size_spec_equal(source, replacement), } }