diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index ff44f4bf6..9ca5ba42f 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -4900,7 +4900,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "tracing", "ts-rs", ] diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 8ec462348..68c131dd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -50,7 +50,7 @@ portable-pty = "0.9" percent-encoding = "2" 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, features = ["ts-bindings", "kind-observability"] } +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/server-rs/Cargo.lock b/server-rs/Cargo.lock index 6d68ada47..5de6bdfbe 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -5238,7 +5238,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "tracing", "ts-rs", ] diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5f92b841a..db2e303eb 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4112,13 +4112,11 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 { /// 图片快速编辑端点允许的**静态图**来源类型。 /// -/// 口径依据 `packages/shared/src/contracts/gameCreationApp.ts` 的 -/// `canonicalGameCreationAppAssetKind()`(等价实现见 -/// `server-rs/crates/shared-contracts/src/game_creation_app.rs`):以「媒体类型为静态图的 -/// canonical 类型」为权威集合,并额外收下合法 legacy 类型本身(平台还有别的写入口按本地 -/// manifest 原始类型登记来源资源,客户端不会先做 canonical 归一)。 -/// `canonicalGameCreationAppAssetKind()` 对未知值回退 `image`,因此两个入口都放行的这些值 -/// 在共享契约里语义等价,静态图集合对 canonical 归一封闭,不会漏放合法静态图来源。 +/// 口径:以「媒体类型为静态图的 canonical 类型」为权威集合,并额外收下平台侧按本地 +/// manifest 原始类型登记来源资源时会出现的等价写法。kind 词汇表本身以 +/// `server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs` 的 +/// `GameCreationAppAssetKind` 为唯一真源(严格解析、无别名表),这里保持的是**对外 API 的 +/// 兼容白名单**(按库原始值放行),不参与、也不代表客户端的 kind 归一规则。 /// /// 必须与 `media_type == image` 一起构成 AND 门:视频、音频、序列帧等非静态媒体即使挂着 /// 图片类 assetKind 也照旧拒绝。 @@ -20762,8 +20760,8 @@ mod tests { (Some("publication-material"), Some("image"), true), (Some("ui-design"), Some("image"), true), (Some("scene"), Some("image"), true), - // `canonicalGameCreationAppAssetKind()` 的合法输入:平台按本地 manifest 原始类型 - // 登记来源资源时会出现这些 legacy 值,实测 `art-spritesheet` / `ui` 曾整片 400。 + // 对外 API 兼容白名单里的平台原始写法:平台按本地 manifest 原始类型登记来源资源时 + // 会出现这些值,实测 `art-spritesheet` / `ui` 曾整片 400。 (Some("art-spritesheet"), Some("image"), true), (Some("art-spritesheet-slice"), Some("image"), true), (Some("ui"), Some("image"), true), diff --git a/server-rs/crates/shared-contracts/Cargo.toml b/server-rs/crates/shared-contracts/Cargo.toml index 1ab34918f..277970c0a 100644 --- a/server-rs/crates/shared-contracts/Cargo.toml +++ b/server-rs/crates/shared-contracts/Cargo.toml @@ -8,14 +8,13 @@ license.workspace = true # 默认给 api-server 等原生后端暴露资产上传 DTO;SpacetimeDB WASM 路径通过 workspace 依赖关闭默认 feature。 default = ["oss-contracts"] oss-contracts = [] -# 导出 TS 绑定与"解析边界留痕"是两件事:前者只关心生成物,后者是可观测性开关, -# 需要它的消费方(AGC 壳)显式打开,避免日志随绑定需求被动启用/被动关闭。 +# 导出 TS 绑定:生成物落在 `packages/shared/src/contracts/generated/`。 +# 解析边界留痕不用 feature 开关:`shared-contracts` 只暴露一个可注册的回调, +# 由壳层(AGC)在启动时接到自己的 `app_log!` 上,见 `asset_kind.rs`。 ts-bindings = ["dep:ts-rs"] -kind-observability = ["dep:tracing"] [dependencies] serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } ts-rs = { workspace = true, optional = true } -tracing = { workspace = true, optional = true } diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 0f3f28678..f0ffea11a 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; mod asset_kind; -pub use asset_kind::GameCreationAppAssetKind; +pub use asset_kind::{ + GameCreationAppAssetKind, NonCanonicalAssetKindReporter, set_non_canonical_asset_kind_reporter, +}; pub const GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION: &str = "game-creation-app.manifest.v1"; pub const GAME_CREATION_AGENT_RUN_SCHEMA_VERSION: &str = "game-creator-agent-run.v1"; @@ -570,55 +572,6 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry { } } -pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS: [&str; 18] = [ - "image", - "scene", - "character", - "character-animation", - "icon", - "icon-spritesheet", - "icon-spec", - "ui-design", - GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND, - "publication-material", - "spec", - "video", - "sound-effect", - "background-music", - "audio", - "font", - "document", - "code", -]; - -pub fn canonical_game_creation_app_asset_kind(value: &str) -> &'static str { - // kind 词汇大小写不敏感;UI Editor 文档写入侧统一使用 UI 文档常量。 - // TS 侧 `canonicalGameCreationAppAssetKind` 用同一口径,由 - // `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 交叉钉住。 - let normalized = value.trim().to_lowercase(); - match normalized.as_str() { - "game-background" => "scene", - "character-art" => "character", - "ui-prototype" => "ui-design", - // 画板导出等现役写入侧仍会写出这些非 canonical 值,必须在这里收口, - // 否则会落到 image 兜底并被 image -> unclassified 误分到「待归类」。 - "ui" => "ui-design", - "art-spritesheet" => "icon-spritesheet", - "art-spritesheet-slice" => "icon", - "illustration" | "game-art" => "image", - "game-entry" | "game-script" | "game-style" => "code", - "animation" => "character-animation", - "asset" => "image", - canonical => match GAME_CREATION_APP_CANONICAL_ASSET_KINDS - .iter() - .find(|candidate| **candidate == canonical) - { - Some(value) => value, - None => "image", - }, - } -} - #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum GameCreationAppAssetCategory { @@ -639,38 +592,90 @@ pub const GAME_CREATION_APP_ASSET_CATEGORIES: [GameCreationAppAssetCategory; 6] GameCreationAppAssetCategory::Unclassified, ]; -/// canonical kind 到功能分类的默认映射,必须穷举 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS`。 -pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: [(&str, GameCreationAppAssetCategory); 18] = [ - ("image", GameCreationAppAssetCategory::Unclassified), - ("scene", GameCreationAppAssetCategory::Scene), - ("character", GameCreationAppAssetCategory::Character), +/// kind 到功能分类的默认映射,**按枚举变体穷举**:少一个变体编译不过, +/// 与 `GameCreationAppAssetKind::ALL` 的一致性由测试 `asset_category_mapping_covers_every_kind` +/// 兜住。这是 kind → 分类的唯一真源,仓库里不再有第二份 canonical 字符串列表。 +pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: &[( + GameCreationAppAssetKind, + GameCreationAppAssetCategory, +)] = &[ ( - "character-animation", - GameCreationAppAssetCategory::Character, - ), - ("icon", GameCreationAppAssetCategory::UiInteraction), - ( - "icon-spritesheet", - GameCreationAppAssetCategory::UiInteraction, - ), - ("icon-spec", GameCreationAppAssetCategory::UiInteraction), - ("ui-design", GameCreationAppAssetCategory::UiInteraction), - ( - GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND, - GameCreationAppAssetCategory::UiInteraction, - ), - ( - "publication-material", + GameCreationAppAssetKind::Image, + GameCreationAppAssetCategory::Unclassified, + ), + ( + GameCreationAppAssetKind::Scene, + GameCreationAppAssetCategory::Scene, + ), + ( + GameCreationAppAssetKind::Character, + GameCreationAppAssetCategory::Character, + ), + ( + GameCreationAppAssetKind::CharacterAnimation, + GameCreationAppAssetCategory::Character, + ), + ( + GameCreationAppAssetKind::Icon, + GameCreationAppAssetCategory::UiInteraction, + ), + ( + GameCreationAppAssetKind::IconSpritesheet, + GameCreationAppAssetCategory::UiInteraction, + ), + ( + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetCategory::UiInteraction, + ), + ( + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetCategory::UiInteraction, + ), + ( + GameCreationAppAssetKind::UiDesignDoc, + GameCreationAppAssetCategory::UiInteraction, + ), + ( + GameCreationAppAssetKind::PublicationMaterial, + GameCreationAppAssetCategory::Unclassified, + ), + ( + GameCreationAppAssetKind::Spec, + GameCreationAppAssetCategory::Document, + ), + ( + GameCreationAppAssetKind::Video, + GameCreationAppAssetCategory::Unclassified, + ), + ( + GameCreationAppAssetKind::Audio, + GameCreationAppAssetCategory::Audio, + ), + ( + GameCreationAppAssetKind::SoundEffect, + GameCreationAppAssetCategory::Audio, + ), + ( + GameCreationAppAssetKind::BackgroundMusic, + GameCreationAppAssetCategory::Audio, + ), + ( + GameCreationAppAssetKind::Font, + GameCreationAppAssetCategory::Document, + ), + ( + GameCreationAppAssetKind::Document, + GameCreationAppAssetCategory::Document, + ), + ( + GameCreationAppAssetKind::Code, + GameCreationAppAssetCategory::Unclassified, + ), + // 认不出的 kind 一律落「待归类」:不做别名归一,也不猜。 + ( + GameCreationAppAssetKind::Unknown, GameCreationAppAssetCategory::Unclassified, ), - ("spec", GameCreationAppAssetCategory::Document), - ("video", GameCreationAppAssetCategory::Unclassified), - ("sound-effect", GameCreationAppAssetCategory::Audio), - ("background-music", GameCreationAppAssetCategory::Audio), - ("audio", GameCreationAppAssetCategory::Audio), - ("font", GameCreationAppAssetCategory::Document), - ("document", GameCreationAppAssetCategory::Document), - ("code", GameCreationAppAssetCategory::Unclassified), ]; pub fn game_creation_app_asset_category_from_str( @@ -688,7 +693,7 @@ pub fn game_creation_app_asset_category_from_str( } pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppAssetCategory { - let canonical = canonical_game_creation_app_asset_kind(kind); + let canonical = GameCreationAppAssetKind::parse_with_context(kind, "asset-category.by-kind"); GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND .iter() .find(|(candidate, _)| *candidate == canonical) @@ -698,15 +703,18 @@ pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppA /// manifest 资产的**有效分类**(读显示口径)。 /// -/// 与 TS 侧 `gameCreationAppAssetCategory` 是同一套口径的两份实现,由 -/// `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析 -/// `EFFECTIVE_CATEGORY_CONTRACT` 决策矩阵交叉钉住,两侧不许各写一份。 +/// 与 TS 侧 `gameCreationAppAssetCategory` 是同一套口径的两份实现,两侧不许各写一份: +/// Rust 侧由本文件的 `EFFECTIVE_CATEGORY_CONTRACT` 决策矩阵用例钉住,TS 侧由 +/// `packages/shared/src/contracts/gameCreationApp.test.ts` 的同口径用例钉住。 /// /// 规则:落盘 `category` 是权威值;**唯一例外**是落盘值 `unclassified` 而该资产 `kind` /// 能派生出明确的非 `unclassified` 分类时采用派生值。这条例外用于自愈历史上被系统误写成 -/// `unclassified` 的存量(典型是 `kind:"ui"` / `kind:"UI"` 因 kind 不在 canonical 目录而 -/// 落到 `image -> unclassified`),不写迁移脚本且永久生效;`kind` 派生结果本身就是 -/// `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。 +/// `unclassified` 的存量(例如 UI 资产落盘 `unclassified` 而 kind 已是 `ui-design`), +/// 不写迁移脚本且永久生效;`kind` 派生结果本身就是 `unclassified` 的 +/// (`image / video / code / publication-material`,以及认不出的 kind)仍信任落盘值。 +/// +/// kind 认不出时解析边界只收口成 `Unknown` 并留痕(见 `GameCreationAppAssetKind:: +/// parse_with_context`),**不查别名表**:这条自愈规则不负责把 legacy kind 映射回 canonical。 /// /// 注意分工:本函数只用于**读显示**(UI 栏目、Agent 投影)。回写 manifest 必须用落盘原值 /// (`GameCreationAppAssetManifestEntry.category` 反序列化后就是落盘原值),否则「编辑标签」 @@ -2181,20 +2189,22 @@ mod tests { } #[test] - fn asset_category_mapping_covers_every_canonical_kind() { + fn asset_category_mapping_covers_every_kind() { assert_eq!( GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND.len(), - GAME_CREATION_APP_CANONICAL_ASSET_KINDS.len() + GameCreationAppAssetKind::ALL.len(), + "分类映射必须与 kind 词汇表逐项对齐" ); - for kind in GAME_CREATION_APP_CANONICAL_ASSET_KINDS { + for kind in GameCreationAppAssetKind::ALL { let mapped = GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND .iter() - .find(|(candidate, _)| *candidate == kind) - .unwrap_or_else(|| panic!("canonical kind 缺少功能分类映射:{kind}")); + .find(|(candidate, _)| candidate == kind) + .unwrap_or_else(|| panic!("kind 缺少功能分类映射:{}", kind.as_str())); assert_eq!( mapped.1, - game_creation_app_asset_category_for_kind(kind), - "canonical kind 映射不一致:{kind}" + game_creation_app_asset_category_for_kind(kind.as_str()), + "kind 映射不一致:{}", + kind.as_str() ); assert!(GAME_CREATION_APP_ASSET_CATEGORIES.contains(&mapped.1)); } @@ -2229,61 +2239,40 @@ mod tests { "{kind} 必须落待归类" ); } - assert_eq!( - game_creation_app_asset_category_for_kind("game-background"), - GameCreationAppAssetCategory::Scene - ); - // 现役写入侧写的是大写 `"UI"`(UI 设计 JSON 资产)与 `font`(字体上传)。 - // 旧断言钉的是「`"UI"` 落待归类」,那正是真机 8 条 UI 资产永远归不了类的成因: - // 派生值本身就是 unclassified,读时自愈也救不回来,只能在别名表收口。 - assert_eq!( - game_creation_app_asset_category_for_kind("UI"), - GameCreationAppAssetCategory::UiInteraction - ); - assert_eq!( - canonical_game_creation_app_asset_kind("UI"), - "ui-design", - "kind 别名表必须大小写不敏感" - ); assert_eq!( game_creation_app_asset_category_for_kind("font"), GameCreationAppAssetCategory::Document ); - // `Object.prototype` 上的键不是别名:Rust 侧是 `match` 字面量,本来就落 `image`; - // TS 侧别名表是对象字面量,必须用 `Object.hasOwn` 挡住原型命中,两侧才一致。 - // 这两条与 TS `gameCreationApp.test.ts` / `assetKindCanonicalMapping.test.ts` 成对。 + // 严格口径:非 canonical 原值(含大小写变体与 `Object.prototype` 上的键)一律落 + // 「待归类」。没有别名表,也不许有 `image` 这类兜底归一。 for prototype_key in ["constructor", "__proto__", "toString", "valueOf"] { - assert_eq!( - canonical_game_creation_app_asset_kind(prototype_key), - "image", - "{prototype_key} 必须落 image 兜底" - ); assert_eq!( game_creation_app_asset_category_for_kind(prototype_key), GameCreationAppAssetCategory::Unclassified ); } - assert_eq!( - game_creation_app_asset_category_for_kind("unknown-kind"), - GameCreationAppAssetCategory::Unclassified - ); + for raw in ["UI", "ui", "game-background", "unknown-kind"] { + assert_eq!( + game_creation_app_asset_category_for_kind(raw), + GameCreationAppAssetCategory::Unclassified, + "{raw} 是非 canonical 值,不得被归一成别的分类" + ); + } } /// 有效分类(读显示)决策矩阵:`(kind, 落盘 category, 有效 category)`。 /// /// **这是「落盘值 / kind 派生 / 读时自愈」三个口径的唯一真源**:本表由下面的 - /// `asset_effective_category_follows_the_shared_contract_matrix` 逐条断言, - /// 同时被 TS 侧 - /// `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析出来喂给 - /// `gameCreationAppAssetCategory` 对照。两侧各写一份矩阵就会重新分叉成 - /// 「UI 显示 ui-interaction、Agent 读 unclassified」的两套口径。 + /// `asset_effective_category_follows_the_shared_contract_matrix` 逐条断言。 + /// TS 侧 `gameCreationAppAssetCategory` 是同一口径的另一份实现,由 + /// `packages/shared/src/contracts/gameCreationApp.test.ts` 的同名矩阵保持同步。 const EFFECTIVE_CATEGORY_CONTRACT: [(&str, &str, &str); 8] = [ // 落盘值合法且明确 → 落盘值为准。 ("character", "character", "character"), ("ui", "scene", "scene"), ("image", "audio", "audio"), // 落盘 unclassified 且 kind 能派生明确分类 → 自愈成派生值。 - ("ui", "unclassified", "ui-interaction"), + ("ui-design", "unclassified", "ui-interaction"), ("character", "unclassified", "character"), // kind 派生结果本身就是 unclassified → 信任落盘值。 ("image", "unclassified", "unclassified"), @@ -2307,7 +2296,7 @@ mod tests { } #[test] - fn asset_manifest_entry_defaults_category_and_tags_for_legacy_payloads() { + fn asset_manifest_entry_defaults_category_and_tags_for_payloads_missing_them() { let character = asset_entry_from_json(asset_entry_json("character")); assert_eq!(character.category, GameCreationAppAssetCategory::Character); assert!(character.tags.is_empty()); @@ -2315,11 +2304,17 @@ mod tests { let icon = asset_entry_from_json(asset_entry_json("icon-spritesheet")); assert_eq!(icon.category, GameCreationAppAssetCategory::UiInteraction); - let legacy_alias = asset_entry_from_json(asset_entry_json("game-background")); - assert_eq!(legacy_alias.category, GameCreationAppAssetCategory::Scene); - - let unknown = asset_entry_from_json(asset_entry_json("unknown-kind")); - assert_eq!(unknown.category, GameCreationAppAssetCategory::Unclassified); + // 缺 `category` 时按 kind 派生;非 canonical 的 kind 没有别名表, + // 一律落「待归类」,判定结果只出现在留痕日志里。 + for raw_kind in ["game-background", "ui", "unknown-kind"] { + let legacy = asset_entry_from_json(asset_entry_json(raw_kind)); + assert_eq!(legacy.kind, GameCreationAppAssetKind::Unknown); + assert_eq!( + legacy.category, + GameCreationAppAssetCategory::Unclassified, + "{raw_kind} 不得被归一成别的分类" + ); + } } #[test] diff --git a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs index 607d9ce7b..3340293ae 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs @@ -2,135 +2,140 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::fmt; use std::ops::Deref; use std::str::FromStr; +use std::sync::RwLock; -/// GameCreationApp manifest 资源 kind 的唯一 Rust 类型。 +/// 「非 canonical 资源 kind」留痕回调:`(原始输入串, 调用上下文)`。 /// -/// JSON 使用 kebab-case;`Unknown` 只表示解析边界遇到尚未登记的输入, -/// 正常资源写入路径不应主动选择它。 -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] -#[cfg_attr( - feature = "ts-bindings", - ts( - export, - export_to = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../../apps/ai-game-creator-shell/src/contracts/generated/" - ) - ) -)] -pub enum GameCreationAppAssetKind { - #[cfg_attr(feature = "ts-bindings", ts(rename = "image"))] - Image, - #[cfg_attr(feature = "ts-bindings", ts(rename = "scene"))] - Scene, - #[cfg_attr(feature = "ts-bindings", ts(rename = "character"))] - Character, - #[cfg_attr(feature = "ts-bindings", ts(rename = "character-animation"))] - CharacterAnimation, - #[cfg_attr(feature = "ts-bindings", ts(rename = "icon"))] - Icon, - #[cfg_attr(feature = "ts-bindings", ts(rename = "icon-spritesheet"))] - IconSpritesheet, - #[cfg_attr(feature = "ts-bindings", ts(rename = "icon-spec"))] - IconSpec, - #[cfg_attr(feature = "ts-bindings", ts(rename = "ui-design"))] - UiDesign, - #[cfg_attr(feature = "ts-bindings", ts(rename = "ui-design-doc"))] - UiDesignDoc, - #[cfg_attr(feature = "ts-bindings", ts(rename = "publication-material"))] - PublicationMaterial, - #[cfg_attr(feature = "ts-bindings", ts(rename = "spec"))] - Spec, - #[cfg_attr(feature = "ts-bindings", ts(rename = "video"))] - Video, - #[cfg_attr(feature = "ts-bindings", ts(rename = "audio"))] - Audio, - #[cfg_attr(feature = "ts-bindings", ts(rename = "sound-effect"))] - SoundEffect, - #[cfg_attr(feature = "ts-bindings", ts(rename = "background-music"))] - BackgroundMusic, - #[cfg_attr(feature = "ts-bindings", ts(rename = "font"))] - Font, - #[cfg_attr(feature = "ts-bindings", ts(rename = "document"))] - Document, - #[cfg_attr(feature = "ts-bindings", ts(rename = "code"))] - Code, - #[cfg_attr(feature = "ts-bindings", ts(rename = "unknown"))] - Unknown, +/// `shared-contracts` 是跨进程共用的 crate,拿不到壳层的日志实现(AGC 壳的 `app_log!`), +/// 所以这里只留一个可注册的回调:解析边界认不出 canonical 值时把**原始输入串**交出去, +/// 壳层注册后即可把 legacy 写入方定位到具体调用点。未注册时静默丢弃(例如后端进程)。 +pub type NonCanonicalAssetKindReporter = fn(raw_kind: &str, context: &str); + +static NON_CANONICAL_ASSET_KIND_REPORTER: RwLock> = + RwLock::new(None); + +/// 注册留痕回调;重复注册以后注册的为准。 +pub fn set_non_canonical_asset_kind_reporter(reporter: NonCanonicalAssetKindReporter) { + match NON_CANONICAL_ASSET_KIND_REPORTER.write() { + Ok(mut slot) => *slot = Some(reporter), + Err(poisoned) => *poisoned.into_inner() = Some(reporter), + } } -impl GameCreationAppAssetKind { - pub const fn as_str(self) -> &'static str { - match self { - Self::Image => "image", - Self::Scene => "scene", - Self::Character => "character", - Self::CharacterAnimation => "character-animation", - Self::Icon => "icon", - Self::IconSpritesheet => "icon-spritesheet", - Self::IconSpec => "icon-spec", - Self::UiDesign => "ui-design", - Self::UiDesignDoc => "ui-design-doc", - Self::PublicationMaterial => "publication-material", - Self::Spec => "spec", - Self::Video => "video", - Self::Audio => "audio", - Self::SoundEffect => "sound-effect", - Self::BackgroundMusic => "background-music", - Self::Font => "font", - Self::Document => "document", - Self::Code => "code", - Self::Unknown => "unknown", - } +fn report_non_canonical_asset_kind(raw_kind: &str, context: &str) { + let reporter = match NON_CANONICAL_ASSET_KIND_REPORTER.read() { + Ok(slot) => *slot, + Err(poisoned) => *poisoned.into_inner(), + }; + if let Some(reporter) = reporter { + reporter(raw_kind, context); } +} - pub fn from_str_lossy(value: &str) -> Self { - match value { - "image" => Self::Image, - "scene" => Self::Scene, - "character" => Self::Character, - "character-animation" => Self::CharacterAnimation, - "icon" => Self::Icon, - "icon-spritesheet" => Self::IconSpritesheet, - "icon-spec" => Self::IconSpec, - "ui-design" => Self::UiDesign, - "ui-design-doc" => Self::UiDesignDoc, - "publication-material" => Self::PublicationMaterial, - "spec" => Self::Spec, - "video" => Self::Video, - "audio" => Self::Audio, - "sound-effect" => Self::SoundEffect, - "background-music" => Self::BackgroundMusic, - "font" => Self::Font, - "document" => Self::Document, - "code" => Self::Code, - _ => Self::Unknown, +/// GameCreationApp manifest 资源 kind 的**唯一**词汇表。 +/// +/// 枚举变体、线上值(kebab-case)、`as_str()`、`ALL`、严格解析与 ts-rs 绑定全部由下面这张 +/// 声明表派生:仓库里不再有第二份 kind 列表(旧的手写 canonical 列表、legacy 别名表、 +/// `font` 特例都已删除),TS 侧也只剩这一份生成 union。 +/// +/// 口径:**只接受 canonical 值**。认不出的输入收口成 `Unknown`,不做 trim、不做大小写归一、 +/// 不查别名表、不做迁移;留痕由 [`Self::parse_with_context`] 交给已注册的 reporter。 +macro_rules! game_creation_app_asset_kinds { + ($($variant:ident => $wire:literal),+ $(,)?) => { + /// GameCreationApp manifest 资源 kind 的**唯一**类型,也是 TS 侧 kind union 的唯一真源 + /// (经 ts-rs 生成,前端不再手写第二份列表)。 + /// + /// JSON 使用 kebab-case;`Unknown` 只表示解析边界遇到尚未登记的输入, + /// 正常资源写入路径不应主动选择它。 + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] + #[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] + #[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) + )] + pub enum GameCreationAppAssetKind { + $( + #[cfg_attr(feature = "ts-bindings", ts(rename = $wire))] + $variant, + )+ } - } - /// 解析外部 manifest/API 字符串;未知值收口到 `Unknown` 并留下可检索的原始值与上下文。 - pub fn parse_with_context(value: &str, context: &'static str) -> Self { - #[cfg(not(feature = "kind-observability"))] - let _ = context; - let parsed = Self::from_str_lossy(value); - if parsed == Self::Unknown && value.trim() != Self::Unknown.as_str() { - #[cfg(feature = "kind-observability")] - tracing::warn!( - raw_kind = value, - source_context = context, - "未知的 GameCreationApp 资源 kind,已收口为 unknown" - ); + impl GameCreationAppAssetKind { + /// 全部变体(末尾是 `Unknown`),按声明顺序。 + pub const ALL: &'static [Self] = &[$(Self::$variant,)+]; + + /// 线上值:kind 字符串表只有这一份,就是上面那张声明表。 + pub const fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $wire,)+ + } + } + + /// 严格解析:只认 canonical 值,其余(含大小写、空白变体)一律 `Unknown`。 + /// + /// 反序列化必须保持不可失败(一份 legacy manifest 不该让整个项目读不出来), + /// 所以这里是"认不出就 `Unknown`",而不是 `Err`;名字里的 `or_unknown` 只描述 + /// 这个收口,不代表任何归一化或兼容。 + pub fn from_str_or_unknown(value: &str) -> Self { + let mut index = 0; + while index < Self::ALL.len() { + let candidate = Self::ALL[index]; + if candidate.as_str() == value { + return candidate; + } + index += 1; + } + Self::Unknown + } + + /// 解析外部 manifest / API 字符串:严格解析,并在认不出 canonical 值时把原始串与 + /// 上下文交给已注册的 reporter(AGC 壳注册成 `app_log!`)。 + /// + /// 字面 `"unknown"` 本身是合法输入,不算异常,不报。 + pub fn parse_with_context(value: &str, context: &'static str) -> Self { + let parsed = Self::from_str_or_unknown(value); + if parsed == Self::Unknown && value != Self::Unknown.as_str() { + report_non_canonical_asset_kind(value, context); + } + parsed + } } - parsed - } + }; +} + +game_creation_app_asset_kinds! { + Image => "image", + Scene => "scene", + Character => "character", + CharacterAnimation => "character-animation", + Icon => "icon", + IconSpritesheet => "icon-spritesheet", + IconSpec => "icon-spec", + UiDesign => "ui-design", + UiDesignDoc => "ui-design-doc", + PublicationMaterial => "publication-material", + Spec => "spec", + Video => "video", + Audio => "audio", + SoundEffect => "sound-effect", + BackgroundMusic => "background-music", + Font => "font", + Document => "document", + Code => "code", + Unknown => "unknown", } impl FromStr for GameCreationAppAssetKind { type Err = std::convert::Infallible; fn from_str(value: &str) -> Result { - Ok(Self::from_str_lossy(value)) + Ok(Self::parse_with_context(value, "from_str")) } } @@ -216,7 +221,35 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetKind { #[cfg(test)] mod tests { - use super::GameCreationAppAssetKind; + use super::{GameCreationAppAssetKind, set_non_canonical_asset_kind_reporter}; + use std::sync::{Mutex, MutexGuard}; + + /// 留痕回调是进程级单例,测试之间必须串行注册/观察。 + static REPORTER_LOCK: Mutex<()> = Mutex::new(()); + static REPORTS: Mutex> = Mutex::new(Vec::new()); + + fn report_into_slot(raw_kind: &str, context: &str) { + if let Ok(mut reports) = REPORTS.lock() { + reports.push((raw_kind.to_string(), context.to_string())); + } + } + + /// 串行持有 reporter 槽位,并把收集器清空后注册回调。 + fn record_reports() -> MutexGuard<'static, ()> { + let guard = match REPORTER_LOCK.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + if let Ok(mut reports) = REPORTS.lock() { + reports.clear(); + } + set_non_canonical_asset_kind_reporter(report_into_slot); + guard + } + + fn recorded_reports() -> Vec<(String, String)> { + REPORTS.lock().expect("reports lock").clone() + } #[test] fn serde_round_trip_uses_kebab_case() { @@ -251,12 +284,18 @@ mod tests { #[test] fn unknown_wire_values_are_closed_over_unknown() { + let _guard = record_reports(); for raw in ["asset", "ui", "game-background", "future-kind"] { assert_eq!( serde_json::from_str::(&format!("\"{raw}\"")).unwrap(), GameCreationAppAssetKind::Unknown ); } + assert_eq!( + recorded_reports().len(), + 4, + "每个非 canonical 的落盘值都应留痕" + ); } #[test] @@ -266,4 +305,52 @@ mod tests { GameCreationAppAssetKind::UiDesign ); } + + #[test] + fn non_canonical_input_is_reported_with_raw_value_and_context() { + let _guard = record_reports(); + assert_eq!( + GameCreationAppAssetKind::parse_with_context("UI", "unit-test.context"), + GameCreationAppAssetKind::Unknown + ); + assert_eq!( + GameCreationAppAssetKind::parse_with_context("unknown", "unit-test.context"), + GameCreationAppAssetKind::Unknown + ); + assert_eq!( + recorded_reports().as_slice(), + [("UI".to_string(), "unit-test.context".to_string())], + "只有非 canonical 的原值才留痕,字面 unknown 不算异常" + ); + } + + /// 词汇表只有一张声明表:`ALL` 覆盖全部变体、线上值互不重复、往返一致; + /// 严格口径下大小写与空白变体都不命中。 + #[test] + fn declared_vocabulary_is_unique_and_strictly_parsed() { + assert_eq!(GameCreationAppAssetKind::ALL.len(), 19); + let mut wire_values = std::collections::BTreeSet::new(); + for kind in GameCreationAppAssetKind::ALL { + assert!( + wire_values.insert(kind.as_str()), + "线上值重复:{}", + kind.as_str() + ); + assert_eq!( + GameCreationAppAssetKind::from_str_or_unknown(kind.as_str()), + *kind + ); + } + assert_eq!( + GameCreationAppAssetKind::from_str_or_unknown("unknown"), + GameCreationAppAssetKind::Unknown + ); + for raw in ["UI", "ui", " image ", "Image", "art-spritesheet-slice"] { + assert_eq!( + GameCreationAppAssetKind::from_str_or_unknown(raw), + GameCreationAppAssetKind::Unknown, + "{raw} 不许命中 canonical 值" + ); + } + } }