From fe85fa2a621be630e7d05addee19e61c6ca2f138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 16:20:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=20AGC=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=20kind=20Rust=20=E6=9E=9A=E4=B8=BE=E4=B8=8E=20TypeScript=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=20=E6=96=B0=E5=A2=9E=20GameCreationAppAssetK?= =?UTF-8?q?ind=20=E5=8F=8A=20Unknown=20=E8=A7=A3=E6=9E=90=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=20=E9=80=9A=E8=BF=87=20ts-rs=20=E7=94=9F=E6=88=90=20s?= =?UTF-8?q?hell=20=E4=B8=93=E5=B1=9E=20TypeScript=20=E8=81=94=E5=90=88?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=20=E8=A1=A5=E5=85=85=E6=9C=AA=E7=9F=A5?= =?UTF-8?q?=E5=80=BC=E4=B8=8A=E4=B8=8B=E6=96=87=E6=97=A5=E5=BF=97=E4=B8=8E?= =?UTF-8?q?=20Rust/=E7=BB=91=E5=AE=9A=E7=94=9F=E6=88=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/Cargo.lock | 2 + .../src-tauri/Cargo.toml | 4 +- .../src/ui_editor/commands/binding.rs | 26 +++ .../generated/GameCreationAppAssetKind.ts | 28 +++ ...】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md | 3 +- ...C资源kind枚举化Rust绑定与内部收紧-2026-09-15.md | 2 +- server-rs/Cargo.lock | 90 +++++--- server-rs/crates/shared-contracts/Cargo.toml | 4 + .../shared-contracts/src/game_creation_app.rs | 3 + .../src/game_creation_app/asset_kind.rs | 210 ++++++++++++++++++ 10 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/contracts/generated/GameCreationAppAssetKind.ts create mode 100644 server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 7e07a3c9c..8cd646b75 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -4900,6 +4900,8 @@ dependencies = [ "serde", "serde_json", "sha2", + "tracing", + "ts-rs", ] [[package]] diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 4fe37d25a..efb48439f 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-i serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } +shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] } tauri-build = { version = "2.6.2", features = [] } [dependencies] @@ -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 } +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/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index 797f8c44d..62d244410 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -15,6 +15,7 @@ use platform_llm::{ }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use shared_contracts::game_creation_app::GameCreationAppAssetKind; use std::collections::HashSet; use std::path::Path; use ts_rs::TS; @@ -645,5 +646,30 @@ mod tests { Node::export_all(&config).expect("Node TypeScript export succeeds"); TextComponent::export_all(&config).expect("TextComponent TypeScript export succeeds"); FontSource::export_all(&config).expect("FontSource TypeScript export succeeds"); + + let game_creation_config = Config::new().with_out_dir(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../src/contracts/generated/" + )); + GameCreationAppAssetKind::export_all(&game_creation_config) + .expect("GameCreationAppAssetKind TypeScript export succeeds"); + let generated = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../src/contracts/generated/GameCreationAppAssetKind.ts" + )) + .expect("generated GameCreationAppAssetKind binding exists"); + for value in [ + "\"image\"", + "\"audio\"", + "\"sound-effect\"", + "\"background-music\"", + "\"font\"", + "\"unknown\"", + ] { + assert!( + generated.contains(value), + "generated binding misses {value}" + ); + } } } diff --git a/apps/ai-game-creator-shell/src/contracts/generated/GameCreationAppAssetKind.ts b/apps/ai-game-creator-shell/src/contracts/generated/GameCreationAppAssetKind.ts new file mode 100644 index 000000000..37ede755d --- /dev/null +++ b/apps/ai-game-creator-shell/src/contracts/generated/GameCreationAppAssetKind.ts @@ -0,0 +1,28 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * GameCreationApp manifest 资源 kind 的唯一 Rust 类型。 + * + * JSON 使用 kebab-case;`Unknown` 只表示解析边界遇到尚未登记的输入, + * 正常资源写入路径不应主动选择它。 + */ +export type GameCreationAppAssetKind = + | 'image' + | 'scene' + | 'character' + | 'character-animation' + | 'icon' + | 'icon-spritesheet' + | 'icon-spec' + | 'ui-design' + | 'ui-design-doc' + | 'publication-material' + | 'spec' + | 'video' + | 'audio' + | 'sound-effect' + | 'background-music' + | 'font' + | 'document' + | 'code' + | 'unknown'; diff --git a/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md index f087c2874..7f76005c8 100644 --- a/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Milestone | `docs/project-memory/plans/【里程碑】AGC资源kind枚举化Rust绑定与内部收紧-2026-09-15.md` | -| Status | ready | +| Status | in-progress | | Owner | Codex | ## 修改边界 @@ -29,6 +29,7 @@ 2. 为 enum 增加可选 ts-rs derive,默认 shared-contracts 构建不引入 ts-rs。 3. 让 shell 的 shared-contracts 依赖打开绑定 feature,在 shell 专属 contracts/generated 目录生成 TS。 4. 增加 Rust serde round-trip 与 Unknown 解析/序列化测试;增加 TS 生成文件存在和值集合测试。 + 未知值通过 `parse_with_context` 收口到 `Unknown`,并在启用 shell 的 `kind-observability` feature 时记录原始值与解析上下文。 5. 运行 shared-contracts 与 shell 定向测试;通过后暂停,等待下一个内部字段收紧切片。 ## 验证命令 diff --git a/docs/project-memory/plans/【里程碑】AGC资源kind枚举化Rust绑定与内部收紧-2026-09-15.md b/docs/project-memory/plans/【里程碑】AGC资源kind枚举化Rust绑定与内部收紧-2026-09-15.md index e9876983b..27aefd851 100644 --- a/docs/project-memory/plans/【里程碑】AGC资源kind枚举化Rust绑定与内部收紧-2026-09-15.md +++ b/docs/project-memory/plans/【里程碑】AGC资源kind枚举化Rust绑定与内部收紧-2026-09-15.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | planned | +| Status | in-progress | | Date | 2026-09-15 | | Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` | diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index d76f865be..6d68ada47 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -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", ] @@ -4856,7 +4847,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5247,6 +5238,8 @@ dependencies = [ "serde", "serde_json", "sha2", + "tracing", + "ts-rs", ] [[package]] @@ -5420,7 +5413,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]] @@ -5529,7 +5522,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", @@ -5815,7 +5808,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]] @@ -6284,6 +6286,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" @@ -6901,7 +6925,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]] @@ -7134,7 +7158,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", diff --git a/server-rs/crates/shared-contracts/Cargo.toml b/server-rs/crates/shared-contracts/Cargo.toml index 7d23e1bd9..012b86c9a 100644 --- a/server-rs/crates/shared-contracts/Cargo.toml +++ b/server-rs/crates/shared-contracts/Cargo.toml @@ -8,8 +8,12 @@ license.workspace = true # 默认给 api-server 等原生后端暴露资产上传 DTO;SpacetimeDB WASM 路径通过 workspace 依赖关闭默认 feature。 default = ["oss-contracts"] oss-contracts = [] +ts-bindings = ["dep:ts-rs", "kind-observability"] +kind-observability = ["dep:tracing"] [dependencies] serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } +ts-rs = { version = "12.0.1", 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 b13ec611c..bd528afb7 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -1,6 +1,9 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; +mod asset_kind; +pub use asset_kind::GameCreationAppAssetKind; + 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; 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 new file mode 100644 index 000000000..fe103682b --- /dev/null +++ b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs @@ -0,0 +1,210 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use std::fmt; +use std::str::FromStr; + +/// GameCreationApp manifest 资源 kind 的唯一 Rust 类型。 +/// +/// 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_to = "GameCreationAppAssetKind.ts"))] +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, +} + +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", + } + } + + 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, + } + } + + /// 解析外部 manifest/API 字符串;未知值收口到 `Unknown` 并留下可检索的原始值与上下文。 + pub fn parse_with_context(value: &str, context: &'static str) -> Self { + 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" + ); + } + parsed + } +} + +impl FromStr for GameCreationAppAssetKind { + type Err = std::convert::Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self::from_str_lossy(value)) + } +} + +impl fmt::Display for GameCreationAppAssetKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl Serialize for GameCreationAppAssetKind { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for GameCreationAppAssetKind { + fn deserialize(deserializer: D) -> Result + 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(self, value: &str) -> Result + where + E: de::Error, + { + Ok(GameCreationAppAssetKind::parse_with_context(value, "serde")) + } + } + + deserializer.deserialize_str(AssetKindVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::GameCreationAppAssetKind; + + #[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::(&format!("\"{encoded}\"")) + .unwrap(), + value + ); + } + } + + #[test] + fn unknown_wire_values_are_closed_over_unknown() { + for raw in ["asset", "ui", "game-background", "future-kind"] { + assert_eq!( + serde_json::from_str::(&format!("\"{raw}\"")).unwrap(), + GameCreationAppAssetKind::Unknown + ); + } + } +}