GameCreationApp 资源 kind 词汇表收敛为 Rust 单源并接上可注册留痕回调
- asset_kind.rs:新增声明宏,由一张 kind 表派生出枚举、线上值 as_str、ALL、严格解析与 ts-rs 绑定,仓库里不再有第二份 kind 列表。 - asset_kind.rs:from_str_lossy 改名 from_str_or_unknown 并去掉 trim / lowercase 归一,parse_with_context 只做严格等值匹配,认不出的原值不查别名、不迁移。 - asset_kind.rs:留痕改为可注册回调 set_non_canonical_asset_kind_reporter,把原始串与调用上下文交给壳层日志,未注册时静默。 - game_creation_app.rs:删除手写 GAME_CREATION_APP_CANONICAL_ASSET_KINDS 与 canonical_game_creation_app_asset_kind 别名表(含 font → document 特例),分类映射表按枚举成员穷举。 - shared-contracts/Cargo.toml:删除 kind-observability feature 与 tracing 依赖,留痕不再靠 tracing。 - api-server/editor_project.rs:图片编辑白名单注释改成「API 兼容面」,不再自称 kind 别名表。
This commit is contained in:
-1
@@ -4900,7 +4900,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
Generated
-1
@@ -5238,7 +5238,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"ts-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),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Option<NonCanonicalAssetKindReporter>> =
|
||||
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<Self, Self::Err> {
|
||||
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<Vec<(String, String)>> = 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::<GameCreationAppAssetKind>(&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 值"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user