合并最新主线并对齐资源类型枚举更新
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m14s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m38s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m39s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m25s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m6s
Project CI / Repository checks (pull_request) Successful in 8m12s
Project CI / Native shell tests (pull_request) Successful in 10m54s
Project CI / Frontend tests (pull_request) Successful in 13m34s
Project CI / Backend tests (pull_request) Successful in 13m55s
Project CI / AI game creator shell web tests (pull_request) Successful in 8m2s

同步 master 的资源类型枚举、共享契约与依赖锁文件更新
保留本分支的窄依赖、集中鉴权和异步追踪实现
通过路由、项目元数据、图片编辑策略和共享契约定向回归
This commit is contained in:
kdletters
2026-09-19 11:35:17 +08:00
130 changed files with 4309 additions and 2243 deletions
+56 -33
View File
@@ -52,17 +52,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -182,7 +171,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -193,7 +182,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1133,7 +1122,7 @@ dependencies = [
"openssl-sys",
"schannel",
"socket2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1148,7 +1137,7 @@ dependencies = [
"openssl-sys",
"pkg-config",
"vcpkg",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1451,7 +1440,7 @@ version = "0.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6"
dependencies = [
"ahash 0.8.12",
"ahash",
"bytemuck",
"document-features",
"egui",
@@ -1486,7 +1475,7 @@ version = "0.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3"
dependencies = [
"ahash 0.8.12",
"ahash",
"bitflags 2.13.0",
"emath",
"epaint",
@@ -1503,7 +1492,7 @@ version = "0.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236"
dependencies = [
"ahash 0.8.12",
"ahash",
"bytemuck",
"document-features",
"egui",
@@ -1598,7 +1587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62"
dependencies = [
"ab_glyph",
"ahash 0.8.12",
"ahash",
"bytemuck",
"ecolor",
"emath",
@@ -1628,7 +1617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2062,9 +2051,6 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
@@ -2094,6 +2080,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]]
name = "heck"
@@ -3084,7 +3075,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3739,7 +3730,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527735ac204efb9fa3884bfd9224d016c5735fabe1d394ebed145b40e7545b99"
dependencies = [
"ahash 0.8.12",
"ahash",
"async-trait",
"blake2",
"bstr",
@@ -3776,7 +3767,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a7ffe2f5acf9f94fd255cfd1438866bc9124f8f0c7d42562bd3f853df2094b7"
dependencies = [
"ahash 0.8.12",
"ahash",
"async-trait",
"brotli",
"bstr",
@@ -3914,7 +3905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6705a26ad89d241a989a5395641931ba37076f5ab5fbd19ee92402414a43af32"
dependencies = [
"arrayvec",
"hashbrown 0.12.3",
"hashbrown 0.17.1",
"parking_lot",
"rand 0.8.6",
]
@@ -4858,7 +4849,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5249,6 +5240,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"ts-rs",
]
[[package]]
@@ -5422,7 +5414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5531,7 +5523,7 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c4e9d07e21ad665c45736871e5005d8457ab3d2cdd7806e351aa5e1c8c2ac32"
dependencies = [
"ahash 0.8.12",
"ahash",
"crossbeam-queue",
"either",
"hashbrown 0.16.1",
@@ -5817,7 +5809,16 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
@@ -6286,6 +6287,28 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror 2.0.18",
"ts-rs-macros",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"termcolor",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -6903,7 +6926,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7136,7 +7159,7 @@ version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [
"ahash 0.8.12",
"ahash",
"android-activity",
"atomic-waker",
"bitflags 2.13.0",
+1
View File
@@ -105,6 +105,7 @@ serde_json = "1"
serde_urlencoded = "0.7"
sha1 = "0.10"
sha2 = "0.10"
ts-rs = "12.0.1"
socket2 = "0.6"
symphonia = { version = "0.5", default-features = false, features = ["mp3"] }
spacetimedb = "=2.8.3"
+13 -189
View File
@@ -38,6 +38,7 @@ use shared_contracts::editor_generation::{
editor_generation_stable_asset_id, editor_generation_stable_asset_object_id,
editor_generation_stable_resource_id,
};
use shared_contracts::game_creation_app::GameCreationAppAssetKind;
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::editor_project::{
EditorGenerationResultPersistItemRecordInput, EditorGenerationResultPersistRecord,
@@ -4110,54 +4111,19 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 {
value.saturating_add(15) / 16 * 16
}
/// 图片快速编辑端点允许的**静态图**来源类型。
///
/// 口径依据 `packages/shared/src/contracts/gameCreationApp.ts` 的
/// `canonicalGameCreationAppAssetKind()`(等价实现见
/// `server-rs/crates/shared-contracts/src/game_creation_app.rs`):以「媒体类型为静态图的
/// canonical 类型」为权威集合,并额外收下合法 legacy 类型本身(平台还有别的写入口按本地
/// manifest 原始类型登记来源资源,客户端不会先做 canonical 归一)。
/// `canonicalGameCreationAppAssetKind()` 对未知值回退 `image`,因此两个入口都放行的这些值
/// 在共享契约里语义等价,静态图集合对 canonical 归一封闭,不会漏放合法静态图来源。
///
/// 必须与 `media_type == image` 一起构成 AND 门:视频、音频、序列帧等非静态媒体即使挂着
/// 图片类 assetKind 也照旧拒绝。
pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 18] = [
// canonical 静态图类型
"image",
"scene",
"character",
"icon",
"icon-spritesheet",
"icon-spec",
"ui-design",
"publication-material",
"spec",
// 指向上述 canonical 类型的合法 legacy 类型
"game-background",
"character-art",
"game-art",
"illustration",
"art-spritesheet",
"art-spritesheet-slice",
"ui-prototype",
// AGC 本地 manifest 等在用的等价静态图类型
"ui",
// `register_local_asset_entry`assets.rs)空 kind 兜底写的是 `"asset"`
// 未归类上传的 PNG 会以 `kind: "asset"` 登记,而共享契约把 `"asset"` canonical 化成
// `"image"`,漏掉它就等于把「未归类静态图」挡在快速编辑之外(400)。
// 白名单必须在 canonical 静态图别名下封闭。
"asset",
];
pub(crate) fn ensure_editor_image_edit_source_kind_allowed(
asset_kind: Option<&str>,
media_type: Option<&str>,
) -> Result<(), AppError> {
let asset_kind = asset_kind.map(str::trim).filter(|value| !value.is_empty());
let asset_kind = asset_kind.filter(|value| !value.trim().is_empty());
let media_type = media_type.map(str::trim).filter(|value| !value.is_empty());
let asset_kind_allowed = asset_kind
.is_none_or(|asset_kind| EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS.contains(&asset_kind));
let asset_kind_allowed = asset_kind.is_none_or(|asset_kind| {
GameCreationAppAssetKind::parse_with_context(
asset_kind,
"editor-image-edit.source.asset_kind",
)
.is_static_image()
});
let media_type_allowed = matches!(media_type, None | Some("image"));
if asset_kind_allowed && media_type_allowed {
return Ok(());
@@ -20769,7 +20735,7 @@ mod tests {
}
#[test]
fn editor_image_edit_source_kind_whitelist_covers_static_image_kinds_only() {
fn editor_image_edit_source_kind_policy_covers_static_image_kinds_only() {
let cases = [
(None, None, true),
(None, Some("image"), true),
@@ -20782,16 +20748,6 @@ 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。
(Some("art-spritesheet"), Some("image"), true),
(Some("art-spritesheet-slice"), Some("image"), true),
(Some("ui"), Some("image"), true),
(Some("ui-prototype"), Some("image"), true),
(Some("game-art"), Some("image"), true),
(Some("game-background"), Some("image"), true),
(Some("character-art"), Some("image"), true),
(Some("illustration"), Some("image"), true),
// 非静态媒体与非静态类型必须继续拒绝。
(Some("character-animation"), Some("image-sequence"), false),
(Some("sound-effect"), Some("audio"), false),
@@ -20803,8 +20759,6 @@ mod tests {
(Some("future-asset-kind"), Some("image"), false),
(None, Some("future-media-type"), false),
// mediaType 是 AND 门:图片类 assetKind 挂非静态媒体照旧拒绝。
(Some("art-spritesheet"), Some("video"), false),
(Some("ui"), Some("audio"), false),
(Some("icon"), Some("image-sequence"), false),
(Some("scene"), Some("image-sequence"), false),
];
@@ -20813,13 +20767,13 @@ mod tests {
assert_eq!(
result.is_ok(),
expected_allowed,
"unexpected whitelist result for assetKind={asset_kind:?}, mediaType={media_type:?}",
"unexpected source-kind policy result for assetKind={asset_kind:?}, mediaType={media_type:?}",
);
}
let error =
ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("video"))
.expect_err("非静态媒体即使挂着图片类 assetKind 不得支持快速编辑");
ensure_editor_image_edit_source_kind_allowed(Some("future-kind"), Some("image"))
.expect_err("未知 kind 不得作为快速编辑来源");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("message")),
@@ -20932,10 +20886,6 @@ mod tests {
ensure_editor_image_edit_source_kind_allowed(Some("icon"), Some("image")).is_ok(),
"icon 是共享契约里的静态图 canonical 类型,与 generationInputs.source 无关"
);
assert!(
ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("image"))
.is_ok()
);
assert!(ensure_editor_image_edit_source_kind_allowed(Some("icon"), Some("video")).is_err());
assert!(
ensure_editor_image_edit_source_kind_allowed(Some("future-kind"), Some("image"))
@@ -20943,132 +20893,6 @@ mod tests {
);
}
/// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,来源资源的 assetKind 是本地
/// manifest 原始类型 `art-spritesheet` / `ui` / `asset`mediaType=image)。这条路径必须
/// 放行;非静态媒体(video/audio/image-sequence)必须继续拒绝。
///
/// 夹具必须真的走进被测路径,不能是自洽空断言:请求体先反序列化成
/// `EditorImageEditRequest`,再从**请求体自己的** `generationInputs.assetKind` 取类型喂给
/// 放行判据;并用源码级闸门断言钉住
/// `resolve_editor_image_edit_source` / `ensure_editor_image_edit_target_matches_source`
/// 两个函数体内不得再出现 `generation_inputs`(历史上 `generationInputs.source` 参与过
/// gating,把 AGC 请求整体挡成 400;解析与对账需要 DB/OSS,源码级断言是无需基础设施
/// 也能钉住这条回归的唯一位置)。
#[test]
fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() {
// 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形。
let agc_payload: EditorImageEditRequest = serde_json::from_str(
r#"{
"prompt": "把这张图改成夜间配色",
"sourceReferenceId": "resource-art-spritesheet",
"generationInputs": {
"source": "ai-game-creator-client",
"assetKind": "art-spritesheet"
}
}"#,
)
.expect("AGC quick edit payload should deserialize into the request DTO");
assert_eq!(
agc_payload.source_reference_id, "resource-art-spritesheet",
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
);
assert_eq!(
agc_payload.target_layer_id, None,
"AGC 快速编辑不带 targetLayerId,走的是「主来源即目标」分支"
);
// 关键:放行判据只吃来源资源的权威 assetKind 与 mediaType。这里用请求体自己声明的
// assetKind 驱动判据——请求体里同时带着 `generationInputs.source`,它不得参与 gating。
let requested_kind = agc_payload
.generation_inputs
.as_ref()
.and_then(|inputs| inputs.pointer("/assetKind"))
.and_then(Value::as_str);
assert_eq!(
requested_kind,
Some("art-spritesheet"),
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
);
assert!(
ensure_editor_image_edit_source_kind_allowed(
normalize_editor_image_edit_resolved_source_kind(requested_kind).as_deref(),
Some("image"),
)
.is_ok(),
"AGC 快速编辑请求不得因为 generationInputs.source 被拒"
);
// 源码级闸门:请求解析与目标对账都不得重新引入 generationInputs 门槛。
let source = include_str!("editor_project.rs");
assert_function_not_contains(
source,
"async fn resolve_editor_image_edit_source(",
"fn ensure_editor_image_edit_source_snapshot_matches(",
&["generation_inputs", "generationInputs"],
);
assert_function_not_contains(
source,
"fn ensure_editor_image_edit_target_matches_source(",
"async fn resolve_editor_image_edit_source(",
&["generation_inputs", "generationInputs"],
);
for (asset_kind, media_type) in [
("art-spritesheet", "image"),
("ui", "image"),
("asset", "image"),
("ui-prototype", "image"),
("game-art", "image"),
("game-background", "image"),
("icon", "image"),
] {
assert!(
ensure_editor_image_edit_source_kind_allowed(Some(asset_kind), Some(media_type))
.is_ok(),
"AGC 快速编辑必须放行 {asset_kind}/{media_type}"
);
}
for (asset_kind, media_type) in [
("art-spritesheet", "video"),
("ui", "audio"),
("asset", "video"),
("video", "video"),
("sound-effect", "audio"),
("background-music", "audio"),
("character-animation", "image-sequence"),
("future-kind", "image"),
] {
assert!(
ensure_editor_image_edit_source_kind_allowed(Some(asset_kind), Some(media_type))
.is_err(),
"非静态来源必须继续拒绝:{asset_kind}/{media_type}"
);
}
// 400 错误体必须带上真实原因,客户端才能诊断(对应 AGC 的
// `editor_api_rejection_reason` 取值顺序修正)。
let error =
ensure_editor_image_edit_source_kind_allowed(Some("future-kind"), Some("image"))
.expect_err("未知类型必须拒绝");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("provider")),
Some(&json!("editor-image-edit"))
);
assert_eq!(
error.details().and_then(|details| details.get("message")),
Some(&json!("当前素材类型不支持图片快速编辑"))
);
assert_eq!(
error.details().and_then(|details| details.get("assetKind")),
Some(&json!("future-kind"))
);
assert_eq!(
error.details().and_then(|details| details.get("mediaType")),
Some(&json!("image"))
);
}
#[test]
fn editor_image_edit_target_binding_prefers_object_id_and_fails_closed() {
let target = EditorImageEditTargetLayerSource {
@@ -8,8 +8,13 @@ license.workspace = true
# 默认给 api-server 等原生后端暴露资产上传 DTOSpacetimeDB WASM 路径通过 workspace 依赖关闭默认 feature。
default = ["oss-contracts"]
oss-contracts = []
# 导出 TS 绑定:生成物落在 `packages/shared/src/contracts/generated/`。
# 解析边界留痕不用 feature 开关:`shared-contracts` 只暴露一个可注册的回调,
# 由壳层(AGC)在启动时接到自己的 `app_log!` 上,见 `asset_kind.rs`。
ts-bindings = ["dep:ts-rs"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
ts-rs = { workspace = true, optional = true }
@@ -1,10 +1,17 @@
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
mod asset_kind;
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";
pub const GAME_CREATION_AGENT_RUN_MAX_PASSES: u8 = 3;
pub const GAME_CREATION_AGENT_TOOL_CALL_MAX: u16 = 128;
pub const GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND: &str = "ui-design-doc";
pub const GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE: &str = "application/json";
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
@@ -491,7 +498,7 @@ pub struct GameCreationAppImageSequenceFrame {
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppAssetManifestEntry {
pub id: String,
pub kind: String,
pub kind: GameCreationAppAssetKind,
pub media_type: String,
pub local_path: String,
pub source: GameCreationAppAssetSource,
@@ -543,7 +550,9 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
// (写侧整结构体覆盖落盘,替换值会真的写回文件)。派生只属于缺字段的读兼容,
// 以及读显示口径 `game_creation_app_asset_effective_category`。
let category = match wire.category.as_deref() {
None => game_creation_app_asset_category_for_kind(&wire.kind),
None => {
game_creation_app_asset_category_for_raw_kind(&wire.kind, "manifest.asset.category")
}
Some(raw) => game_creation_app_asset_category_from_str(raw).ok_or_else(|| {
<D::Error as serde::de::Error>::custom(format!(
"未知的素材分类 category = {raw}:本客户端会整结构体重写 manifest,\
@@ -553,7 +562,7 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
};
Ok(Self {
id: wire.id,
kind: wire.kind,
kind: GameCreationAppAssetKind::parse_with_context(&wire.kind, "manifest.asset.kind"),
media_type: wire.media_type,
local_path: wire.local_path,
source: wire.source,
@@ -565,60 +574,6 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
}
}
pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS: [&str; 16] = [
"image",
"scene",
"character",
"character-animation",
"icon",
"icon-spritesheet",
"icon-spec",
"ui-design",
"publication-material",
"spec",
"video",
"sound-effect",
"background-music",
"audio",
"document",
"code",
];
pub fn canonical_game_creation_app_asset_kind(value: &str) -> &'static str {
// kind 词汇大小写不敏感:UI 设计资产的现役写入侧写的是**大写** `"UI"`
// `apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs` 的
// `register_local_asset_at(..., "UI", ...)``workflow.rs` / `persistence.rs` 同),
// 只按小写收口会让它落到 `image` 兜底、再经 `image -> unclassified` 永远停在
// 「待归类」;且派生值本身就是 unclassified,读时自愈也救不回来。
// 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",
// 字体上传(`ttf / otf / woff / woff2`)登记的 manifest 资产 kind 就是 `font`
// 不收口同样只能落「待归类」。
"font" => "document",
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,33 +594,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); 16] = [
("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),
(
"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),
("document", GameCreationAppAssetCategory::Document),
("code", GameCreationAppAssetCategory::Unclassified),
];
pub fn game_creation_app_asset_category_from_str(
@@ -682,32 +694,46 @@ 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);
pub fn game_creation_app_asset_category_for_kind(
kind: GameCreationAppAssetKind,
) -> GameCreationAppAssetCategory {
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND
.iter()
.find(|(candidate, _)| *candidate == canonical)
.find(|(candidate, _)| *candidate == kind)
.map(|(_, category)| *category)
.unwrap_or(GameCreationAppAssetCategory::Unclassified)
}
/// 解析外部 kind 后再按共享枚举派生分类;内部业务逻辑应传入已解析的枚举。
pub fn game_creation_app_asset_category_for_raw_kind(
raw_kind: &str,
context: &'static str,
) -> GameCreationAppAssetCategory {
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::parse_with_context(
raw_kind, context,
))
}
/// 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`)。
///
/// 注意分工:本函数只用于**读显示**(UI 栏目、Agent 投影)。回写 manifest 必须用落盘原值
/// `GameCreationAppAssetManifestEntry.category` 反序列化后就是落盘原值),否则「编辑标签」
/// 面板会把自愈值写回去,把「只改标签」变成静默改分类。
pub fn game_creation_app_asset_effective_category(
kind: &str,
kind: GameCreationAppAssetKind,
persisted: GameCreationAppAssetCategory,
) -> GameCreationAppAssetCategory {
if persisted == GameCreationAppAssetCategory::Unclassified {
@@ -2078,7 +2104,7 @@ mod tests {
});
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-player".to_string(),
kind: "character".to_string(),
kind: GameCreationAppAssetKind::Character,
media_type: "image".to_string(),
local_path: "assets/images/player.png".to_string(),
image_sequence_frames: None,
@@ -2168,109 +2194,98 @@ mod tests {
}
#[test]
fn asset_category_mapping_covers_every_canonical_kind() {
fn manifest_unknown_kind_is_preserved_as_unknown() {
let entry = asset_entry_from_json(asset_entry_json("future-kind"));
assert_eq!(entry.kind, GameCreationAppAssetKind::Unknown);
let serialized = serde_json::to_value(entry).expect("unknown asset entry serializes");
assert_eq!(serialized["kind"], "unknown");
}
#[test]
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),
"kind 映射不一致:{}",
kind.as_str()
);
assert!(GAME_CREATION_APP_ASSET_CATEGORIES.contains(&mapped.1));
}
assert_eq!(
game_creation_app_asset_category_for_kind("icon"),
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Icon),
GameCreationAppAssetCategory::UiInteraction
);
assert_eq!(
game_creation_app_asset_category_for_kind("ui-design"),
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::UiDesign),
GameCreationAppAssetCategory::UiInteraction
);
assert_eq!(
game_creation_app_asset_category_for_kind("character-animation"),
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::CharacterAnimation),
GameCreationAppAssetCategory::Character
);
assert_eq!(
game_creation_app_asset_category_for_kind("scene"),
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Scene),
GameCreationAppAssetCategory::Scene
);
assert_eq!(
game_creation_app_asset_category_for_kind("sound-effect"),
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::SoundEffect),
GameCreationAppAssetCategory::Audio
);
assert_eq!(
game_creation_app_asset_category_for_kind("spec"),
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Spec),
GameCreationAppAssetCategory::Document
);
for kind in ["image", "video", "code", "publication-material"] {
assert_eq!(
game_creation_app_asset_category_for_kind(kind),
game_creation_app_asset_category_for_raw_kind(kind, "test.category"),
GameCreationAppAssetCategory::Unclassified,
"{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"),
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Font),
GameCreationAppAssetCategory::Document
);
// `Object.prototype` 上的键不是别名:Rust 侧是 `match` 字面量,本来就落 `image`
// TS 侧别名表是对象字面量,必须用 `Object.hasOwn` 挡住原型命中,两侧才一致
// 这两条与 TS `gameCreationApp.test.ts` / `assetKindCanonicalMapping.test.ts` 成对。
// 严格口径:非 canonical 原值(含大小写变体与 `Object.prototype` 上的键)一律落
// 「待归类」
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),
game_creation_app_asset_category_for_raw_kind(prototype_key, "test.category"),
GameCreationAppAssetCategory::Unclassified
);
}
assert_eq!(
game_creation_app_asset_category_for_kind("unknown-kind"),
GameCreationAppAssetCategory::Unclassified
);
for raw in ["UI", "future-kind", "unknown-kind"] {
assert_eq!(
game_creation_app_asset_category_for_raw_kind(raw, "test.category"),
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"),
("ui-design", "scene", "scene"),
("image", "audio", "audio"),
// 落盘 unclassified 且 kind 能派生明确分类 → 自愈成派生值。
("ui", "unclassified", "ui-interaction"),
("ui-design", "unclassified", "ui-interaction"),
("character", "unclassified", "character"),
// kind 派生结果本身就是 unclassified → 信任落盘值。
("image", "unclassified", "unclassified"),
@@ -2286,7 +2301,10 @@ mod tests {
let expected = game_creation_app_asset_category_from_str(expected)
.unwrap_or_else(|| panic!("矩阵里的期望分类非法:{expected}"));
assert_eq!(
game_creation_app_asset_effective_category(kind, persisted),
game_creation_app_asset_effective_category(
GameCreationAppAssetKind::parse_with_context(kind, "test.effective-category"),
persisted,
),
expected,
"有效分类矩阵不一致:kind={kind} persisted={persisted:?}"
);
@@ -2294,7 +2312,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());
@@ -2302,11 +2320,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 ["future-kind", "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]
@@ -0,0 +1,465 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use std::fmt;
use std::ops::Deref;
use std::str::FromStr;
use std::sync::RwLock;
/// 「非 canonical 资源 kind」留痕回调:`(原始输入串, 调用上下文)`。
///
/// `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),
}
}
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);
}
}
/// GameCreationApp manifest 资源 kind 的**唯一**词汇表。
///
/// 枚举变体、线上值(kebab-case)、`as_str()`、`ALL`、严格解析与 ts-rs 绑定全部由下面这张
/// 声明表派生,TS 侧消费生成的 union。
///
/// 口径:**只接受 canonical 值**。认不出的输入收口成 `Unknown`;留痕由
/// [`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,
)+
}
impl GameCreationAppAssetKind {
/// 全部变体(末尾是 `Unknown`),按声明顺序。
pub const ALL: &'static [Self] = &[$(Self::$variant,)+];
/// `canvas.asset_generate` 当前允许提交的资源 kind 子集。
///
/// 工具输入与 manifest 持久化共用同一组 canonical 枚举值,不再维护第二份
/// 字符串白名单。工具 schema、prompt 和执行前校验都应从这里派生。
pub const CANVAS_ASSET_KINDS: &'static [Self] = &[
Self::Image,
Self::IconSpec,
Self::UiDesign,
Self::IconSpritesheet,
];
/// 线上值:kind 字符串表只有这一份,就是上面那张声明表。
pub const fn as_str(self) -> &'static str {
match self {
$(Self::$variant => $wire,)+
}
}
/// 资源编辑器和摘要视图共用的音频资源族。
pub const fn is_audio(self) -> bool {
matches!(
self,
Self::Audio | Self::SoundEffect | Self::BackgroundMusic
)
}
/// 资源编辑器和摘要视图共用的视觉资源族。
pub const fn is_visual(self) -> bool {
matches!(
self,
Self::Image
| Self::Scene
| Self::Character
| Self::CharacterAnimation
| Self::Icon
| Self::IconSpritesheet
| Self::IconSpec
| Self::UiDesign
)
}
/// 可作为单张静态图片来源的资源族。
pub const fn is_static_image(self) -> bool {
matches!(
self,
Self::Image
| Self::Scene
| Self::Character
| Self::Icon
| Self::IconSpritesheet
| Self::IconSpec
| Self::UiDesign
| Self::PublicationMaterial
| Self::Spec
)
}
/// 严格解析:只认 canonical 值,其余(含大小写、空白变体)一律 `Unknown`。
///
/// 私有实现细节,**不是**解析入口:外部一律走 [`Self::parse_with_context`]
/// 保证"认不出"这件事只在一个地方被收口、被留痕。
fn match_canonical(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
}
/// **唯一**的 kind 解析入口:严格等值匹配,认不出 canonical 值时把原始串与上下文
/// 交给已注册的 reporterAGC 壳注册成 `app_log!`)。
///
/// 认不出就收口成 `Unknown`,而不是 `Err`:反序列化必须不可失败,
/// 以便调用方决定后续处理。
/// 字面 `"unknown"` 本身是合法输入,不算异常,不报。
pub fn parse_with_context(value: &str, context: &'static str) -> Self {
let parsed = Self::match_canonical(value);
if parsed == Self::Unknown && value != Self::Unknown.as_str() {
report_non_canonical_asset_kind(value, context);
}
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::parse_with_context(value, "from_str"))
}
}
impl fmt::Display for GameCreationAppAssetKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl AsRef<str> for GameCreationAppAssetKind {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Deref for GameCreationAppAssetKind {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_str()
}
}
impl PartialEq<str> for GameCreationAppAssetKind {
fn eq(&self, other: &str) -> bool {
self.as_str() == other
}
}
impl PartialEq<&str> for GameCreationAppAssetKind {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
impl PartialEq<String> for GameCreationAppAssetKind {
fn eq(&self, other: &String) -> bool {
self.as_str() == other
}
}
impl Serialize for GameCreationAppAssetKind {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for GameCreationAppAssetKind {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct AssetKindVisitor;
impl<'de> de::Visitor<'de> for AssetKindVisitor {
type Value = GameCreationAppAssetKind;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a GameCreationApp asset kind string")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(GameCreationAppAssetKind::parse_with_context(value, "serde"))
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: de::Error,
{
self.visit_str(&value)
}
}
deserializer.deserialize_str(AssetKindVisitor)
}
}
#[cfg(test)]
mod tests {
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() {
let values = [
(GameCreationAppAssetKind::Image, "image"),
(
GameCreationAppAssetKind::CharacterAnimation,
"character-animation",
),
(GameCreationAppAssetKind::UiDesignDoc, "ui-design-doc"),
(GameCreationAppAssetKind::SoundEffect, "sound-effect"),
(
GameCreationAppAssetKind::BackgroundMusic,
"background-music",
),
(GameCreationAppAssetKind::Font, "font"),
(GameCreationAppAssetKind::Unknown, "unknown"),
];
for (value, encoded) in values {
assert_eq!(
serde_json::to_string(&value).unwrap(),
format!("\"{encoded}\"")
);
assert_eq!(
serde_json::from_str::<GameCreationAppAssetKind>(&format!("\"{encoded}\""))
.unwrap(),
value
);
}
}
#[test]
fn unknown_wire_values_are_closed_over_unknown() {
let _guard = record_reports();
for raw in ["future-kind", "not-a-kind", "IMAGE", " image "] {
assert_eq!(
serde_json::from_str::<GameCreationAppAssetKind>(&format!("\"{raw}\"")).unwrap(),
GameCreationAppAssetKind::Unknown
);
}
assert_eq!(
recorded_reports().len(),
4,
"每个非 canonical 的落盘值都应留痕"
);
}
#[test]
fn escaped_wire_strings_are_deserialized() {
assert_eq!(
serde_json::from_str::<GameCreationAppAssetKind>(r#""ui\u002Ddesign""#).unwrap(),
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() {
let _guard = record_reports();
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::parse_with_context(kind.as_str(), "unit-test.vocabulary"),
*kind
);
}
assert_eq!(
GameCreationAppAssetKind::parse_with_context("unknown", "unit-test.vocabulary"),
GameCreationAppAssetKind::Unknown
);
for raw in ["IMAGE", " image ", "Image", "future-kind"] {
assert_eq!(
GameCreationAppAssetKind::parse_with_context(raw, "unit-test.vocabulary"),
GameCreationAppAssetKind::Unknown,
"{raw} 不许命中 canonical 值"
);
}
// 每个非 canonical 变体都要留痕。
assert_eq!(recorded_reports().len(), 4);
}
#[test]
fn media_family_helpers_cover_the_shared_kind_sets() {
let audio = GameCreationAppAssetKind::ALL
.iter()
.copied()
.filter(|kind| kind.is_audio())
.collect::<Vec<_>>();
assert_eq!(
audio,
vec![
GameCreationAppAssetKind::Audio,
GameCreationAppAssetKind::SoundEffect,
GameCreationAppAssetKind::BackgroundMusic,
]
);
let visual = GameCreationAppAssetKind::ALL
.iter()
.copied()
.filter(|kind| kind.is_visual())
.collect::<Vec<_>>();
assert_eq!(
visual,
vec![
GameCreationAppAssetKind::Image,
GameCreationAppAssetKind::Scene,
GameCreationAppAssetKind::Character,
GameCreationAppAssetKind::CharacterAnimation,
GameCreationAppAssetKind::Icon,
GameCreationAppAssetKind::IconSpritesheet,
GameCreationAppAssetKind::IconSpec,
GameCreationAppAssetKind::UiDesign,
]
);
let static_images = GameCreationAppAssetKind::ALL
.iter()
.copied()
.filter(|kind| kind.is_static_image())
.collect::<Vec<_>>();
assert_eq!(
static_images,
vec![
GameCreationAppAssetKind::Image,
GameCreationAppAssetKind::Scene,
GameCreationAppAssetKind::Character,
GameCreationAppAssetKind::Icon,
GameCreationAppAssetKind::IconSpritesheet,
GameCreationAppAssetKind::IconSpec,
GameCreationAppAssetKind::UiDesign,
GameCreationAppAssetKind::PublicationMaterial,
GameCreationAppAssetKind::Spec,
]
);
}
}