diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs index c6b20f6dd..ef18c3780 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs @@ -676,11 +676,11 @@ async function runSelfTest() { designFoundationAssetCall?.arguments?.input?.outputPath === 'assets/ui-prototype.png' && designFoundationAssetCall.arguments.input.aspectRatio === '16:9' && - designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' && + designFoundationAssetCall.arguments.input.assetKind === 'ui-design' && artAssetPlanAssetCall?.arguments?.input?.outputPath === 'assets/art-spritesheet.png' && artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' && - artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet', + artAssetPlanAssetCall.arguments.input.assetKind === 'icon-spritesheet', 'self-test-visual-assets-invalid', ); diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 32a9b8644..7957d9956 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -70,7 +70,7 @@ const requiredFormalArtifactSpecs = [ { path: 'game/balance.json', kind: 'json' }, { path: 'assets/manifest.art.json', kind: 'json' }, { path: 'assets/manifest.audio.json', kind: 'json' }, - { path: 'game/index.html', kind: 'game-entry' }, + { path: 'game/index.html', kind: 'file' }, { path: 'exports/README.md', kind: 'file' }, ]; const editorImageArtifactSpecs = [ diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index c0a748ad3..9c8318c57 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -290,7 +290,7 @@ function deterministicArtManifest( { id: 'garden-guardians-spritesheet', path: 'assets/art-spritesheet.png', - kind: 'art-spritesheet', + kind: 'icon-spritesheet', usage: ['defenders', 'enemies', 'battlefield-ui'], source: 'canvas', status: 'ready', @@ -710,7 +710,7 @@ function canvasAssetCall(agentId) { outputPath: 'assets/ui-prototype.png', aspectRatio: '16:9', imageSize: '2K', - assetKind: 'ui-prototype', + assetKind: 'ui-design', assetLabel: '游戏横屏界面原型图', replaceExisting: false, }); @@ -721,7 +721,7 @@ function canvasAssetCall(agentId) { outputPath: 'assets/art-spritesheet.png', aspectRatio: '1:1', imageSize: '1K', - assetKind: 'art-spritesheet', + assetKind: 'icon-spritesheet', assetLabel: '游戏首版核心美术素材', replaceExisting: false, sliceMode: 'connected-components', @@ -830,12 +830,12 @@ function missingGeneratedVisualAssetObservation(context, agentId) { }, 'design-foundation': { path: 'assets/ui-prototype.png', - kind: 'ui-prototype', + kind: 'ui-design', summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务', }, 'art-asset-plan': { path: 'assets/art-spritesheet.png', - kind: 'art-spritesheet', + kind: 'icon-spritesheet', summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务', }, }[agentId]; @@ -2667,7 +2667,7 @@ function createDeterministicCanvasFixture(apiKey) { asset: { assetId: `asset-${sliceImageId}`, assetObjectId: sliceAssetObjectId, - assetKind: 'art-spritesheet-slice', + assetKind: 'icon', projectId, taskId, }, @@ -2707,7 +2707,7 @@ function createDeterministicCanvasFixture(apiKey) { spritesheetAsset: { assetId: `asset-${imageId}`, assetObjectId, - assetKind: 'art-spritesheet', + assetKind: 'icon-spritesheet', projectId, taskId, }, @@ -2749,7 +2749,7 @@ function createDeterministicCanvasFixture(apiKey) { const assetObjectId = `asset-object-${imageId}`; const resourceId = `resource-${imageId}`; const assetKind = - typeof body?.assetKind === 'string' ? body.assetKind : 'game-art'; + typeof body?.assetKind === 'string' ? body.assetKind : 'image'; images.set(imageId, { ...image, objectKey, diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 6d4201d96..ffd9d63b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.24", + "version": "2026-08-26.25", "skills": [ { "name": "agc-game-production-workflow", @@ -63,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c" + "sha256": "6668bf1aa69601bcc65c97fdcd879c699c3139befcd70805d95614997f6e44e9" }, { "name": "agc-web-game-development", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md index 2c737d022..2ed440460 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md @@ -19,13 +19,13 @@ image, UI design image, or publication material; use `agc_edit_image` for an edit of an existing registered image; use `taonier_prepare_game_art` only for the complete game-art package and its canonical slices. -With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"` +With `agc_generate_image`, `kind="character"` and `kind="icon-spritesheet"` generate the subject on a solid-colour background and automatically matte it away afterwards, producing transparent-background results; write the prompt for the subject only, never for a scene. `kind="image"` keeps the rendered frame without extra processing. -When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is +When `agc_generate_image` is used with `kind="icon-spritesheet"`, `sliceMode` is required and has no default, so decide it explicitly: - Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs index 86416a42d..952cfbe93 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs @@ -1198,7 +1198,7 @@ mod tests { "tool": "agc_generate_image", "arguments": { "prompt": prompt, - "kind": "art-spritesheet", + "kind": "icon-spritesheet", "sliceMode": "connected-components", "sliceCount": 8, "screenColor": "#CFEFFF" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index a5d978e6c..27074eb8c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -327,7 +327,7 @@ struct DirectTaonierArtAssetIdentity { local_asset_id: String, source_sha256: String, media_type: String, - canonical_asset_kind: String, + canonical_asset_kind: GameCreationAppAssetKind, resource_id: String, asset_object_id: String, canvas_project_id: String, @@ -846,10 +846,20 @@ pub(in crate::agent) fn direct_taonier_regeneration_workflow_retains_stage_ledge if agent_id != "direct-codex-art" { return Ok(false); } + // TODO: `run_id` 是 Direct 重生成阶段槽标识,不是 manifest `asset_kind`;两者必须保持独立。 let (output_path, asset_kind) = match run_id { - "art-spec" => (DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec"), - "game-background" => (DIRECT_CODEX_BACKGROUND_ASSET_PATH, "game-background"), - "art-spritesheet" => (DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "art-spritesheet"), + "art-spec" => ( + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + GameCreationAppAssetKind::IconSpec, + ), + "game-background" => ( + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + GameCreationAppAssetKind::Scene, + ), + "art-spritesheet" => ( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + GameCreationAppAssetKind::IconSpritesheet, + ), _ => return Ok(false), }; if read_direct_taonier_regeneration_workflow_at(root)?.is_none() { @@ -882,9 +892,18 @@ fn write_direct_taonier_regeneration_workflow_at( fn clear_direct_taonier_regeneration_stage_ledgers_at(root: &Path) -> Result<(), String> { for (output_path, asset_kind) in [ - (DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec"), - (DIRECT_CODEX_BACKGROUND_ASSET_PATH, "game-background"), - (DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "art-spritesheet"), + ( + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + GameCreationAppAssetKind::IconSpec, + ), + ( + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + GameCreationAppAssetKind::Scene, + ), + ( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + GameCreationAppAssetKind::IconSpritesheet, + ), ] { let context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; remove_platform_art_generation_runtime_state_at(root, &context.agent_id, &context.run_id)?; @@ -902,9 +921,9 @@ fn direct_taonier_regeneration_stage_ledgers_exist_at(root: &Path) -> bool { fn validate_direct_taonier_retained_stage_ledgers_at(root: &Path) -> Result<(), String> { for (run_id, asset_kind) in [ - ("art-spec", "icon-spec"), - ("game-background", "game-background"), - ("art-spritesheet", "art-spritesheet"), + ("art-spec", GameCreationAppAssetKind::IconSpec), + ("game-background", GameCreationAppAssetKind::Scene), + ("art-spritesheet", GameCreationAppAssetKind::IconSpritesheet), ] { let exists = game_creator_agent_runtime_external_generation_exists(root, "direct-codex-art", run_id); @@ -916,7 +935,8 @@ fn validate_direct_taonier_retained_stage_ledgers_at(root: &Path) -> Result<(), ) .map_err(|error| { format!( - "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法校验陶泥儿 {asset_kind} 阶段账本,已保留现场:{error}" + "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法校验陶泥儿 {} 阶段账本,已保留现场:{error}", + asset_kind.as_str() ) })? } else { @@ -957,7 +977,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at( let context = direct_taonier_art_generation_runtime_context( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, )?; let retained = retained_platform_art_generation_runtime_spritesheet_identity_at( root, @@ -2471,7 +2491,7 @@ fn persist_direct_codex_failure_context( fn direct_taonier_art_generation_runtime_context( root: &Path, output_path: &str, - asset_kind: &str, + asset_kind: GameCreationAppAssetKind, ) -> Result { let project_id = read_manifest(&root.join(".agent/manifest.json"))? .project_id @@ -2534,7 +2554,7 @@ fn direct_taonier_art_asset_identity( local_asset_id: asset.id.clone(), source_sha256: validated.content_sha256, media_type: asset.media_type.clone(), - canonical_asset_kind: asset.kind.to_string(), + canonical_asset_kind: asset.kind, resource_id: asset .source .resource_id @@ -3185,7 +3205,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( local_asset_id: art_spec.local_asset_id.clone(), source_sha256: art_spec.source_sha256.clone(), media_type: art_spec.media_type.clone(), - canonical_asset_kind: art_spec.canonical_asset_kind.clone(), + canonical_asset_kind: art_spec.canonical_asset_kind, resource_id: remote_spec_resource_id, asset_object_id: spec_binding.asset_object_id, canvas_project_id: project_binding.remote_project_id, @@ -3374,24 +3394,29 @@ async fn generate_direct_taonier_art_asset_at( prompt: &str, output_path: &str, aspect_ratio: &str, - asset_kind: &str, + asset_kind: GameCreationAppAssetKind, asset_label: &str, require_slices: bool, retain_runtime_state: bool, ) -> Result { + let parsed_asset_kind = asset_kind; + if parsed_asset_kind == GameCreationAppAssetKind::Unknown { + return Err("直连美术生成的资源 kind 无法解析".to_string()); + } let options = PlatformArtAssetGenerationOptions { output_path: Some(output_path.to_string()), aspect_ratio: aspect_ratio.to_string(), image_size: "1K".to_string(), - asset_kind: asset_kind.to_string(), + asset_kind: parsed_asset_kind, asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), // 标准美术包必须产出四张 canonical 切片:连通域模式下显式声明目标数量, // 让平台要么给出四张,要么以可执行的 422 说明实际识别数量。 - slice_count: (asset_kind == "art-spritesheet").then_some(4), + slice_count: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet).then_some(4), // 切分模式没有默认值:陶泥儿标准美术包按自由排布生成核心图集,因此只在 // art-spritesheet 阶段显式声明连通域切分。 - slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()), + slice_mode: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet) + .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, screen_color: None, @@ -3615,7 +3640,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( prompt, DIRECT_CODEX_ART_SPEC_ASSET_PATH, "1:1", - "icon-spec", + GameCreationAppAssetKind::IconSpec, "陶泥儿首版游戏视觉规范图", false, mode.regenerates_existing(), @@ -3696,7 +3721,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( prompt, DIRECT_CODEX_BACKGROUND_ASSET_PATH, "16:9", - "game-background", + GameCreationAppAssetKind::Scene, "陶泥儿首版游戏场景背景", false, mode.regenerates_existing(), @@ -3813,7 +3838,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( prompt, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "1:1", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "陶泥儿首版核心游戏美术图集", true, mode.regenerates_existing(), @@ -7375,7 +7400,7 @@ mod tests { let context = direct_taonier_art_generation_runtime_context( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ) .expect("direct stage context"); let request_body = serde_json::json!({ @@ -7996,7 +8021,7 @@ mod tests { local_asset_id: "local-art-spec".to_string(), source_sha256: "a".repeat(64), media_type: "image/png".to_string(), - canonical_asset_kind: "icon-spec".to_string(), + canonical_asset_kind: GameCreationAppAssetKind::IconSpec, resource_id: "taonier-resource-icon-spec".to_string(), asset_object_id: "taonier-object-icon-spec".to_string(), canvas_project_id: "taonier-project".to_string(), @@ -8529,7 +8554,7 @@ mod tests { let context = direct_taonier_art_generation_runtime_context( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ) .expect("spritesheet runtime context"); let request_body = serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index cc0dbd90a..a5ad7dfeb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1213,9 +1213,9 @@ fn bridge_registered_resource( "mediaType": asset.media_type, // Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory` // (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。 - // 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、 + // 直接透传落盘 `asset.category` 会让 UI 设计资产在 UI 显示「UI 交互」、 // 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。 - "category": game_creation_app_asset_effective_category(asset.kind.as_str(), asset.category), + "category": game_creation_app_asset_effective_category(asset.kind, asset.category), "tags": asset.tags, "canvasProjectId": asset.source.canvas_project_id, "resourceId": asset.source.resource_id, @@ -2203,35 +2203,37 @@ async fn bridge_prepare_game_art(state: &DirectToolBridgeState, arguments: &Valu result } -fn bridge_image_generation_kind(arguments: &Value) -> Result { +fn bridge_image_generation_kind(arguments: &Value) -> Result { let kind = arguments .get("kind") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("image"); - normalize_platform_art_asset_generation_kind(kind) - .map(str::to_string) - .ok_or_else(|| { - format!( - "工具参数 kind 只允许 {}", - PLATFORM_ART_ASSET_GENERATION_KINDS.join("、") - ) - }) + normalize_platform_art_asset_generation_kind(kind).ok_or_else(|| { + format!( + "工具参数 kind 只允许 {}", + PLATFORM_ART_ASSET_GENERATION_KINDS + .iter() + .map(|kind| kind.as_str()) + .collect::>() + .join("、") + ) + }) } /// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。 fn validate_generate_image_slice_declaration( - kind: &str, + kind: GameCreationAppAssetKind, slice_mode: Option<&str>, grid_x: Option, grid_y: Option, slice_count: Option, ) -> Result<(), String> { - if kind == "art-spritesheet" { + if kind == GameCreationAppAssetKind::IconSpritesheet { if slice_mode.is_none() { return Err( - "kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components" + "kind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components" .to_string(), ); } @@ -2244,19 +2246,19 @@ fn validate_generate_image_slice_declaration( } if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() { return Err(format!( - "工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=art-spritesheet 生效,当前 kind={kind}" + "工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=icon-spritesheet 生效,当前 kind={kind}" )); } Ok(()) } -/// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为 +/// 抠图纯色背景只服务 character 与 icon-spritesheet 链路;格式校验收口为 /// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为 /// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝, /// 避免服务端静默忽略造成“已生效”的误解。 fn normalize_generate_image_screen_color( arguments: &Value, - kind: &str, + kind: GameCreationAppAssetKind, ) -> Result, String> { let Some(value) = arguments.get("screenColor") else { return Ok(None); @@ -2264,9 +2266,12 @@ fn normalize_generate_image_screen_color( if value.is_null() { return Ok(None); } - if !matches!(kind, "character" | "art-spritesheet") { + if !matches!( + kind, + GameCreationAppAssetKind::Character | GameCreationAppAssetKind::IconSpritesheet + ) { return Err(format!( - "工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}" + "工具参数 screenColor 仅对 kind=character 和 kind=icon-spritesheet 生效,当前 kind={kind}" )); } let raw = value @@ -2393,18 +2398,18 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) }) .transpose()?; validate_generate_image_slice_declaration( - kind.as_str(), + kind, slice_mode.as_deref(), grid_x, grid_y, slice_count, )?; - let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?; + let screen_color = normalize_generate_image_screen_color(arguments, kind)?; let options = PlatformArtAssetGenerationOptions { output_path, aspect_ratio, image_size, - asset_kind: kind.clone(), + asset_kind: kind, asset_label: asset_name.clone(), replace_existing: false, slice_count, @@ -2891,14 +2896,19 @@ pub(crate) async fn start_direct_tool_bridge( mod tests { #[test] fn generate_image_slice_declaration_is_explicit_and_self_consistent() { - let missing = - validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None) - .expect_err("art-spritesheet without sliceMode must fail closed"); + let missing = validate_generate_image_slice_declaration( + GameCreationAppAssetKind::IconSpritesheet, + None, + None, + None, + None, + ) + .expect_err("icon-spritesheet without sliceMode must fail closed"); assert!(missing.contains("没有默认值"), "{missing}"); assert!(missing.contains("connected-components"), "{missing}"); assert!(validate_generate_image_slice_declaration( - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, Some("connected-components"), None, None, @@ -2906,7 +2916,7 @@ mod tests { ) .is_ok()); assert!(validate_generate_image_slice_declaration( - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, Some("grid"), Some(3), Some(2), @@ -2914,7 +2924,7 @@ mod tests { ) .is_ok()); let grid_with_count = validate_generate_image_slice_declaration( - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, Some("grid"), Some(2), Some(2), @@ -2924,58 +2934,80 @@ mod tests { assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}"); let wrong_kind = validate_generate_image_slice_declaration( - "image", + GameCreationAppAssetKind::Image, Some("connected-components"), None, None, None, ) - .expect_err("slice declaration must stay scoped to art-spritesheet"); + .expect_err("slice declaration must stay scoped to icon-spritesheet"); assert!( - wrong_kind.contains("仅对 kind=art-spritesheet 生效"), + wrong_kind.contains("仅对 kind=icon-spritesheet 生效"), "{wrong_kind}" ); - let wrong_kind_count = - validate_generate_image_slice_declaration("image", None, None, None, Some(8)) - .expect_err("sliceCount-only violation must be rejected"); + let wrong_kind_count = validate_generate_image_slice_declaration( + GameCreationAppAssetKind::Image, + None, + None, + None, + Some(8), + ) + .expect_err("sliceCount-only violation must be rejected"); assert!( wrong_kind_count.contains("sliceCount"), "sliceCount-only violation must name sliceCount: {wrong_kind_count}" ); - assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok()); + assert!(validate_generate_image_slice_declaration( + GameCreationAppAssetKind::Image, + None, + None, + None, + None + ) + .is_ok()); } #[test] fn generate_image_screen_color_is_normalized_and_kind_gated() { // 省略与显式 null 等价,且不触发 kind 门禁。 assert_eq!( - normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"), + normalize_generate_image_screen_color(&json!({}), GameCreationAppAssetKind::Image) + .expect("omitted"), None ); assert_eq!( - normalize_generate_image_screen_color(&json!({"screenColor": null}), "image") - .expect("null"), + normalize_generate_image_screen_color( + &json!({"screenColor": null}), + GameCreationAppAssetKind::Image + ) + .expect("null"), None ); // auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。 for raw in ["auto", "AUTO", " auto ", ""] { assert_eq!( - normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character") - .expect("auto variants"), + normalize_generate_image_screen_color( + &json!({"screenColor": raw}), + GameCreationAppAssetKind::Character + ) + .expect("auto variants"), None, "{raw}" ); } // hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。 assert_eq!( - normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character") - .expect("lowercase hex"), + normalize_generate_image_screen_color( + &json!({"screenColor": "#cfefff"}), + GameCreationAppAssetKind::Character + ) + .expect("lowercase hex"), Some("#CFEFFF".to_string()) ); assert_eq!( normalize_generate_image_screen_color( &json!({"screenColor": " #A0BBA0 "}), - "art-spritesheet" + GameCreationAppAssetKind::IconSpritesheet ) .expect("padded hex"), Some("#A0BBA0".to_string()) @@ -2983,19 +3015,24 @@ mod tests { // 非 auto/非 hex、非字符串一律拒绝。 for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] { assert!( - normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character") - .is_err(), + normalize_generate_image_screen_color( + &json!({"screenColor": bad}), + GameCreationAppAssetKind::Character + ) + .is_err(), "{bad}" ); } // 其它 kind 携带该字段直接拒绝,即使取值合法。 - let gated = - normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image") - .expect_err("screenColor must stay scoped to character/art-spritesheet"); + let gated = normalize_generate_image_screen_color( + &json!({"screenColor": "#CFEFFF"}), + GameCreationAppAssetKind::Image, + ) + .expect_err("screenColor must stay scoped to character/icon-spritesheet"); assert!(gated.contains("kind=character"), "{gated}"); assert!(normalize_generate_image_screen_color( &json!({"screenColor": "auto"}), - "ui-prototype" + GameCreationAppAssetKind::UiDesign ) .is_err()); } @@ -3064,28 +3101,6 @@ mod tests { .contains("x-genarrative-client:")); } - /// `asset.list` 的 `kind` 过滤必须报错,而不是静默返回空列表:旧值会收口成 `unknown` 再参与 - /// 等值过滤,匹配不到任何资产,让模型误以为项目里没有这类资产。校验必须发生在项目访问之前。 - #[test] - fn bridge_asset_list_rejects_non_canonical_kind_filter() { - let root = tempfile::tempdir().expect("bridge root"); - let rejected = - bridge_list_registered_assets(root.path(), &json!({ "kind": "art-spritesheet-slice" })); - assert_eq!(rejected["isError"], true); - assert!( - rejected.to_string().contains("art-spritesheet-slice"), - "错误里必须带上原值,调用方才能自纠:{rejected}" - ); - // 字面 `unknown` 仍是合法过滤值:它应当越过 kind 校验,失败点换成项目侧(此目录未初始化)。 - let allowed = bridge_list_registered_assets(root.path(), &json!({ "kind": "unknown" })); - assert!( - !allowed - .to_string() - .contains("不是已知的 manifest 资源 kind"), - "未知 kind 的字面量本身是合法输入:{allowed}" - ); - } - /// 按 kind 的提示词上限只来自客户端权威口径;超限必须在构造工具输入时就被拒绝, /// 不能再出现写死的数字(2026-09-17 的背景音乐 140 就是写死在桥这一层的)。 #[test] @@ -3791,7 +3806,9 @@ mod tests { local_path: "assets/art-spritesheet-slices/player.png".to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"), + category: game_creation_app_asset_category_for_kind( + GameCreationAppAssetKind::IconSpritesheet, + ), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, @@ -3856,7 +3873,9 @@ mod tests { }, ]), image_sequence_duration_ms: Some(4_000), - category: game_creation_app_asset_category_for_kind("character-animation"), + category: game_creation_app_asset_category_for_kind( + GameCreationAppAssetKind::CharacterAnimation, + ), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index f08ff2ef3..95c0b09a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -247,7 +247,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "enum": PLATFORM_ART_ASSET_GENERATION_KINDS, "default": "image", - "description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图" + "description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图" }, "aspectRatio": { "type": "string", @@ -273,7 +273,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "sliceMode": { "type": "string", "enum": ["connected-components", "grid"], - "description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择" + "description": "仅 kind=icon-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择" }, "gridX": { "type": "integer", @@ -291,11 +291,11 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "integer", "minimum": 1, "maximum": 256, - "description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别" + "description": "只与 kind=icon-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别" }, "screenColor": { "type": "string", - "description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近" + "description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=icon-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近" } }, "required": ["prompt"], @@ -2523,7 +2523,7 @@ mod tests { // 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。 let unsupported_kind = call_agc_generate_image(&json!({ "prompt": "像素月光主角", - "kind": "game-art" + "kind": "future-kind" })) .await; assert_eq!(unsupported_kind["isError"], json!(true)); @@ -2533,7 +2533,7 @@ mod tests { let oversized_prompt = call_agc_generate_image(&json!({ "prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1), - "kind": "art-spritesheet" + "kind": "icon-spritesheet" })) .await; assert_eq!(oversized_prompt["isError"], json!(true)); @@ -2902,7 +2902,7 @@ mod tests { local_path: local_path.to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("image"), + category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Image), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index ae9420835..e2e300f20 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -413,7 +413,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) output_path: Option, pub(crate) aspect_ratio: String, pub(crate) image_size: String, - pub(crate) asset_kind: String, + pub(crate) asset_kind: GameCreationAppAssetKind, pub(crate) asset_label: String, pub(crate) replace_existing: bool, pub(crate) slice_count: Option, @@ -421,7 +421,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) grid_x: Option, pub(crate) grid_y: Option, /// 抠图纯色背景(auto/省略已归一为 None;Some 时是规范化后的大写 #RRGGBB)。 - /// 仅 character 与 art-spritesheet 链路透传给服务端。 + /// 仅 character 与 icon-spritesheet 链路透传给服务端。 pub(crate) screen_color: Option, } @@ -431,7 +431,7 @@ impl Default for PlatformArtAssetGenerationOptions { output_path: None, aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "game-art".to_string(), + asset_kind: GameCreationAppAssetKind::Image, asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, slice_count: None, @@ -448,55 +448,27 @@ impl Default for PlatformArtAssetGenerationOptions { /// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录, /// 两条调用路径不允许各写一套白名单;目录外的 kind 一律拒绝,不做兜底猜测。 /// -/// `spec` 只是平台图片生成的 generation kind 名称,客户端真正验证过的规范图类型是 -/// `icon-spec`:`build_platform_art_asset_prompt`、`platform_art_asset_art_spec` 与 -/// `canonical_art_spec_reference_at` 都只认 `icon-spec`,登记成 `spec` 的图既拿不到规范图 -/// 提示词,也无法作为 UI 原型与透明图集的权威参考。因此 `spec` 在 -/// `normalize_platform_art_asset_generation_kind` 里统一收口到 `icon-spec`。 -pub(crate) const PLATFORM_ART_ASSET_GENERATION_KINDS: &[&str] = &[ - "image", - "character", - "spec", - "icon-spec", - "ui-prototype", - "art-spritesheet", - "publication-material", +/// 生成通道只接受这些 canonical manifest kind;平台请求的 `generationKind` 是独立协议字段, +/// 由执行阶段根据 enum 成员派生。 +pub(crate) const PLATFORM_ART_ASSET_GENERATION_KINDS: &[GameCreationAppAssetKind] = &[ + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + GameCreationAppAssetKind::PublicationMaterial, ]; /// 把外部传入的 kind 收口到 `PlatformArtAssetGenerationOptions::asset_kind` 的权威取值。 /// 返回 `None` 表示该 kind 未被验证过,调用方必须拒绝。 -pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option<&'static str> { - let kind = kind.trim(); - let canonical = PLATFORM_ART_ASSET_GENERATION_KINDS +pub(crate) fn normalize_platform_art_asset_generation_kind( + kind: &str, +) -> Option { + let parsed = GameCreationAppAssetKind::parse_with_context(kind, "canvas.asset_kind"); + PLATFORM_ART_ASSET_GENERATION_KINDS .iter() - .find(|candidate| **candidate == kind)?; - Some(if *canonical == "spec" { - "icon-spec" - } else { - canonical - }) -} - -/// 把平台生成适配层收到的 `asset_kind` 映射为 manifest 资产的正式 kind。 -/// -/// 生成工具的新输入由 `GameCreationAppAssetKind::CANVAS_ASSET_KINDS` 约束;这里保留对 -/// 平台请求值和旧生成账本值的显式映射,只在登记边界转换为 manifest canonical kind, -/// 避免把平台字段直接写入 `assets[].kind`。 -/// -/// 词表外的值不猜:返回 `Unknown`,由调用方按既有未知输入口径处理。 -pub(crate) fn platform_art_asset_manifest_kind(asset_kind: &str) -> GameCreationAppAssetKind { - match asset_kind.trim() { - "image" | "game-art" => GameCreationAppAssetKind::Image, - "character" | "character-art" => GameCreationAppAssetKind::Character, - "spec" | "icon-spec" => GameCreationAppAssetKind::IconSpec, - "ui-prototype" | "ui-design" => GameCreationAppAssetKind::UiDesign, - "art-spritesheet" | "icon-spritesheet" => GameCreationAppAssetKind::IconSpritesheet, - "publication-material" => GameCreationAppAssetKind::PublicationMaterial, - "game-background" | "scene" => GameCreationAppAssetKind::Scene, - // 其它平台适配输入也只在这里映射到 manifest kind;认不出的原值收口成 `Unknown` - // 并留痕(回调接到壳层 `app_log!`),manifest 侧不再维护第二张别名表。 - _ => GameCreationAppAssetKind::parse_with_context(asset_kind, "canvas.asset_kind"), - } + .copied() + .find(|candidate| *candidate == parsed) } pub(in crate::agent) fn recover_persisted_visual_generation_options( @@ -514,7 +486,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( "assets/art-spec.png", "1:1", "1K", - "icon-spec", + GameCreationAppAssetKind::IconSpec, "游戏统一视觉规范图", "spec", ), @@ -522,7 +494,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( "assets/ui-prototype.png", "16:9", "2K", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, "游戏横屏界面原型图", "ui-design", ), @@ -530,7 +502,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( "assets/art-spritesheet.png", "1:1", "1K", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "游戏首版核心美术素材", "icon-spritesheet", ), @@ -550,15 +522,17 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( if options.output_path.is_none() { options.output_path = Some(path.to_string()); } - for (value, default) in [ - (&mut options.aspect_ratio, ratio), - (&mut options.image_size, size), - (&mut options.asset_kind, kind), - (&mut options.asset_label, label), - ] { - if value.is_empty() { - *value = default.to_string(); - } + if options.aspect_ratio.is_empty() { + options.aspect_ratio = ratio.to_string(); + } + if options.image_size.is_empty() { + options.image_size = size.to_string(); + } + if options.asset_kind == GameCreationAppAssetKind::Unknown { + options.asset_kind = kind; + } + if options.asset_label.is_empty() { + options.asset_label = label.to_string(); } let snapshot = platform_art_generation_runtime_request_snapshot(&state)?; // 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。 @@ -2205,8 +2179,8 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - if options.asset_kind != "art-spritesheet" { - return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { + return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); } let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); let runtime_context = @@ -2240,7 +2214,7 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { output_path: Option<&'a str>, aspect_ratio: &'a str, image_size: &'a str, - asset_kind: &'a str, + asset_kind: &'a GameCreationAppAssetKind, asset_label: &'a str, replace_existing: bool, require_slices: bool, @@ -2408,7 +2382,7 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_direct_stage_at( root: &Path, runtime_context: &PlatformArtGenerationRuntimeContext, - expected_asset_kind: &str, + expected_asset_kind: GameCreationAppAssetKind, ) -> Result { let Some(state) = read_platform_art_generation_runtime_state(root, runtime_context)? else { return Ok(false); @@ -2425,21 +2399,22 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d .and_then(|value| value.get("assetType")) .and_then(serde_json::Value::as_str); let matches = match expected_asset_kind { - "icon-spec" => { + GameCreationAppAssetKind::IconSpec => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" && snapshot.reference_resource_ids.is_empty() - && request_asset_kind.as_deref() == Some("icon-spec") - && art_spec_asset_type == Some("icon-spec") + && request_asset_kind.as_deref() + == Some(GameCreationAppAssetKind::IconSpec.as_str()) + && art_spec_asset_type == Some(GameCreationAppAssetKind::IconSpec.as_str()) } - "game-background" => { + GameCreationAppAssetKind::Scene => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" && snapshot.reference_resource_ids.len() == 1 - && request_asset_kind.as_deref() == Some("game-background") + && request_asset_kind.as_deref() == Some(GameCreationAppAssetKind::Scene.as_str()) && art_spec_asset_type == Some("background") } - "art-spritesheet" => { + GameCreationAppAssetKind::IconSpritesheet => { snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" && snapshot.generation_kind == "icon-spritesheet" && snapshot.reference_resource_ids.len() == 1 @@ -2459,7 +2434,7 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_spritesheet_ide if !retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, runtime_context, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, )? { return Ok(None); } @@ -2500,11 +2475,11 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( retain_runtime_state: bool, runtime_context: &PlatformArtGenerationRuntimeContext, ) -> Result { - if require_slices && options.asset_kind != "art-spritesheet" { - return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + if require_slices && options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { + return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); } // 切分模式没有默认值:图集生成必须在客户端显式声明,缺失或自相矛盾都在付费提交前失败。 - if options.asset_kind == "art-spritesheet" { + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { let Some(slice_mode) = options.slice_mode.as_deref() else { return Err( "图集生成必须显式声明 sliceMode:等分网格或固定槽位用 grid 并提供 gridX/gridY,自由排布用 connected-components" @@ -2526,7 +2501,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( ); } } else if options.slice_mode.is_some() || options.grid_x.is_some() || options.grid_y.is_some() { - return Err("sliceMode/gridX/gridY 仅对 art-spritesheet 生效".to_string()); + return Err("sliceMode/gridX/gridY 仅对 icon-spritesheet 生效".to_string()); } if super::external_generation_state::is_standalone_platform_art_generation_runtime_context( runtime_context, @@ -2708,8 +2683,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at )); } if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" + options.asset_kind, + GameCreationAppAssetKind::UiDesign + | GameCreationAppAssetKind::Scene + | GameCreationAppAssetKind::IconSpritesheet ) { let current_reference = canonical_art_spec_reference_at( root, @@ -2828,18 +2805,21 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } else { let canvas_context = prepare_external_canvas_generation_context(root, &client, &binding_access).await?; - let generation_kind = match options.asset_kind.as_str() { - "ui-prototype" => "ui-design", - "art-spritesheet" => "icon-spritesheet", - "image" => "image", - "character" => "character", - "publication-material" => "publication-material", + let generation_kind = match options.asset_kind { + GameCreationAppAssetKind::UiDesign => "ui-design", + GameCreationAppAssetKind::IconSpritesheet => "icon-spritesheet", + GameCreationAppAssetKind::Image => "image", + GameCreationAppAssetKind::Character => "character", + GameCreationAppAssetKind::PublicationMaterial => "publication-material", _ => "spec", }; - let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; + let is_canonical_art_spritesheet = + options.asset_kind == GameCreationAppAssetKind::IconSpritesheet; let canonical_reference = if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" + options.asset_kind, + GameCreationAppAssetKind::UiDesign + | GameCreationAppAssetKind::Scene + | GameCreationAppAssetKind::IconSpritesheet ) { Some( canonical_art_spec_reference_at( @@ -2889,7 +2869,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "kind": generation_kind, "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, - "assetKind": options.asset_kind, + "assetKind": options.asset_kind.as_str(), "assetLabel": options.asset_label, "projectId": canvas_context.project_id, "assetFolderId": canvas_context.asset_folder_id, @@ -2905,16 +2885,18 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) }; let request_body = if matches!( - options.asset_kind.as_str(), - "image" | "character" | "publication-material" + options.asset_kind, + GameCreationAppAssetKind::Image + | GameCreationAppAssetKind::Character + | GameCreationAppAssetKind::PublicationMaterial ) { let mut request_body = request_body; if let Some(object) = request_body.as_object_mut() { object.remove("generationInputs"); - if options.asset_kind == "image" { + if options.asset_kind == GameCreationAppAssetKind::Image { object.remove("kind"); } - if options.asset_kind == "character" { + if options.asset_kind == GameCreationAppAssetKind::Character { if let Some(screen_color) = options.screen_color.as_deref() { object.insert( "screenColor".to_string(), @@ -6989,7 +6971,7 @@ fn register_platform_art_slice_manifest_entries_at( image_sequence_frames: None, image_sequence_duration_ms: None, category: game_creation_app_asset_category_for_kind( - GameCreationAppAssetKind::Icon.as_str(), + GameCreationAppAssetKind::Icon, ), tags: Vec::new(), source, @@ -7745,7 +7727,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( let registered = match register_local_asset_entry( root, &local_path, - platform_art_asset_manifest_kind(&options.asset_kind), + options.asset_kind, &download.media_type, "platform-art", GameCreationAppAssetSource { @@ -7874,7 +7856,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( pub(crate) fn platform_art_asset_art_spec( options: &PlatformArtAssetGenerationOptions, ) -> serde_json::Value { - if options.asset_kind == "icon-spec" { + if options.asset_kind == GameCreationAppAssetKind::IconSpec { return serde_json::json!({ "assetType": "icon-spec", "subject": "游戏角色、场景、UI 图标与反馈特效的统一视觉规范", @@ -7886,7 +7868,7 @@ pub(crate) fn platform_art_asset_art_spec( "references": [], }); } - if options.asset_kind == "ui-prototype" { + if options.asset_kind == GameCreationAppAssetKind::UiDesign { return serde_json::json!({ "assetType": "ui", "subject": "严格依据当前项目玩法合同生成的完整游戏 UI 原型,包含状态 HUD、主要可玩区域、关键实体、操作控件、失败与重开流程", @@ -7898,7 +7880,7 @@ pub(crate) fn platform_art_asset_art_spec( "references": [], }); } - if options.asset_kind == "game-background" { + if options.asset_kind == GameCreationAppAssetKind::Scene { return serde_json::json!({ "assetType": "background", "subject": format!("{};使用项目原创命名和原创场景设计", options.asset_label), @@ -7925,37 +7907,37 @@ pub(crate) fn build_platform_art_asset_prompt( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> String { - if options.asset_kind == "image" { + if options.asset_kind == GameCreationAppAssetKind::Image { return format!( "根据用户需求生成一张全新的原创图片。主体、环境、风格、构图、光线和色彩以用户描述为准;不要生成素材图集、规范展板、完整游戏截图或文字说明。不得修改或复述为已有图片编辑。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "character" { + if options.asset_kind == GameCreationAppAssetKind::Character { return format!( "根据用户需求生成一张全新的原创角色形象或人物立绘。清楚表现角色外貌、服饰、姿势、表情、画风、构图和背景;只生成一张完整图片,不要生成图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "publication-material" { + if options.asset_kind == GameCreationAppAssetKind::PublicationMaterial { return format!( "根据用户需求生成一张全新的原创游戏发布宣传图。突出主体、卖点、氛围、构图、色彩和适合发布展示的画面层次;只生成一张完整图片,不要生成素材图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "icon-spec" { + if options.asset_kind == GameCreationAppAssetKind::IconSpec { return format!( "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "ui-prototype" { + if options.asset_kind == GameCreationAppAssetKind::UiDesign { return format!( "生成一张真正的游戏 UI/UX 原型图,不是场景概念图。必须严格从下方当前项目 UI 需求提取玩法,不得自行假设它属于塔防或加入需求中不存在的单位卡牌、费用、波次、敌人入口等结构。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画无 HUD 场景、概念图、地图、海报或纯插画。玩法机制只用于理解功能,不授权复刻现有作品;必须采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言,不得使用现有游戏 Logo、贴图、标志性布局或受保护视觉语言。\n\n当前项目 UI 需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "game-background" { + if options.asset_kind == GameCreationAppAssetKind::Scene { return format!( "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) @@ -7986,102 +7968,47 @@ mod canvas_generation_tests { }; #[test] - fn generation_kind_catalog_normalizes_spec_onto_the_verified_icon_spec() { - // 目录就是两条调用路径共同的可生成集合,必须逐字固定。 + fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() { assert_eq!( PLATFORM_ART_ASSET_GENERATION_KINDS, &[ - "image", - "character", - "spec", - "icon-spec", - "ui-prototype", - "art-spritesheet", - "publication-material" + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + GameCreationAppAssetKind::PublicationMaterial, ] ); - for kind in ["image", "character", "ui-prototype", "art-spritesheet"] { + for kind in [ + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + ] { assert_eq!( - normalize_platform_art_asset_generation_kind(kind), + normalize_platform_art_asset_generation_kind(kind.as_str()), Some(kind), - "{kind} 必须原样落到 assetKind" + "{} 必须原样落到 assetKind", + kind.as_str() ); } - // `spec` 只是服务端 generation kind;客户端验证过的规范图类型是 icon-spec, - // 提示词与规范图引用都只认它,所以这里必须收口而不是放行原值。 - assert_eq!( - normalize_platform_art_asset_generation_kind("spec"), - Some("icon-spec") - ); - assert_eq!( - normalize_platform_art_asset_generation_kind("icon-spec"), - Some("icon-spec") - ); - assert_eq!( - normalize_platform_art_asset_generation_kind(" art-spritesheet "), - Some("art-spritesheet") - ); - // 未验证的 kind 一律拒绝,不做兜底猜测。 - for kind in ["game-art", "game-background", "UI", "asset", "scene", ""] { + for kind in ["future-kind", "", "UI"] { assert_eq!( normalize_platform_art_asset_generation_kind(kind), None, - "{kind} 不在已审核目录内,必须被拒绝" - ); - } - } - - /// 平台请求词汇 → manifest kind 的**唯一**映射点:逐条别名、trim 与未映射值的落点都必须钉住。 - /// - /// 未映射值不能悄悄变成某个具体 kind:它走严格解析,认不出就是 `Unknown`(→「待归类」), - /// 同时由 reporter 把原始串写进日志——这条用例保证"不猜"。 - #[test] - fn platform_art_asset_manifest_kind_maps_platform_vocabulary_explicitly() { - use GameCreationAppAssetKind as Kind; - for (platform_kind, expected) in [ - ("image", Kind::Image), - ("game-art", Kind::Image), - ("character", Kind::Character), - ("character-art", Kind::Character), - ("spec", Kind::IconSpec), - ("icon-spec", Kind::IconSpec), - ("ui-prototype", Kind::UiDesign), - ("ui-design", Kind::UiDesign), - ("art-spritesheet", Kind::IconSpritesheet), - ("icon-spritesheet", Kind::IconSpritesheet), - ("publication-material", Kind::PublicationMaterial), - ("game-background", Kind::Scene), - ("scene", Kind::Scene), - // 平台词汇这一步会 trim(与 `normalize_platform_art_asset_generation_kind` 同口径)。 - (" ui-prototype ", Kind::UiDesign), - ] { - assert_eq!( - platform_art_asset_manifest_kind(platform_kind), - expected, - "{platform_kind} 的映射必须显式钉住" - ); - } - // canonical 成员即使不在上面的平台别名表里也照常解析(严格等值)。 - assert_eq!( - platform_art_asset_manifest_kind("font"), - GameCreationAppAssetKind::Font - ); - // 认不出的词汇不猜、不兜底成具体 kind。 - for platform_kind in ["", "UI", "ui", "mystery", "unknown-kind"] { - assert_eq!( - platform_art_asset_manifest_kind(platform_kind), - GameCreationAppAssetKind::Unknown, - "{platform_kind} 不在映射表内,必须落 unknown 而不是被猜成某个 kind" + "{kind} 不是当前生成入口的 canonical kind" ); } } #[test] fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() { - // 放行 art-spritesheet 必须真的走到图集提示词与图集请求合同, + // 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同, // 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。 let options = PlatformArtAssetGenerationOptions { - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, ..PlatformArtAssetGenerationOptions::default() }; assert!( @@ -8284,7 +8211,7 @@ mod canvas_generation_tests { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/manual-concurrent.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "并发手工素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -8462,7 +8389,7 @@ mod canvas_generation_tests { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/manual-failed-retry.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "失败后可重试素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -8515,7 +8442,7 @@ mod canvas_generation_tests { output_path: Some("assets/manual-art.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "game-background".to_string(), + asset_kind: GameCreationAppAssetKind::Scene, asset_label: "手工背景".to_string(), replace_existing: true, slice_count: None, @@ -8581,7 +8508,7 @@ mod canvas_generation_tests { changed.image_size = "1K".to_string(); changed_options.push(changed); let mut changed = options.clone(); - changed.asset_kind = "ui-prototype".to_string(); + changed.asset_kind = GameCreationAppAssetKind::UiDesign; changed_options.push(changed); let mut changed = options.clone(); changed.asset_label = "另一个标签".to_string(); @@ -8636,7 +8563,7 @@ mod canvas_generation_tests { .expect("init independent slot project"); let options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -8904,13 +8831,13 @@ mod canvas_generation_tests { // 升级前它们共用同一个 (automatic-output) 槽,因此第二条必然被拒;升级后各自成槽。 let first_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "并发素材一".to_string(), ..PlatformArtAssetGenerationOptions::default() }; let second_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "并发素材二".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -9018,7 +8945,7 @@ mod canvas_generation_tests { .expect("init legacy slot project"); let options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -9138,7 +9065,7 @@ mod canvas_generation_tests { // 另一个精确动作的旧槽账本不得被本次动作消费、改写或删除。 let other_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "另一个图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -10566,7 +10493,7 @@ mod canvas_generation_tests { output_path: Some("assets/art-spec.png".to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "整包规范图".to_string(), replace_existing: false, slice_count: None, @@ -10959,7 +10886,7 @@ mod canvas_generation_tests { }; let current_prompt = "账号 A 已提交的生成请求"; let current_options = PlatformArtAssetGenerationOptions { - asset_kind: "game-art".to_string(), + asset_kind: GameCreationAppAssetKind::Image, asset_label: "账号隔离测试".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -11167,7 +11094,7 @@ mod canvas_generation_tests { let current_prompt = "恢复时使用同一个接受后生成意图"; let current_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "game-art".to_string(), + asset_kind: GameCreationAppAssetKind::Image, asset_label: "当前标签".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -11471,7 +11398,7 @@ mod canvas_generation_tests { output_path: Some("assets/direct-game-background.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "1K".to_string(), - asset_kind: "game-background".to_string(), + asset_kind: GameCreationAppAssetKind::Scene, asset_label: "整包背景图".to_string(), replace_existing: false, slice_count: None, @@ -11936,7 +11863,7 @@ mod canvas_generation_tests { output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, slice_count: None, @@ -11978,7 +11905,7 @@ mod canvas_generation_tests { output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), aspect_ratio: String::new(), image_size: String::new(), - asset_kind: String::new(), + asset_kind: GameCreationAppAssetKind::Unknown, asset_label: String::new(), ..Default::default() }; @@ -11996,7 +11923,7 @@ mod canvas_generation_tests { .unwrap() .is_none()); let mut explicit_options = sparse_options.clone(); - explicit_options.asset_kind = "game-background".to_string(); + explicit_options.asset_kind = GameCreationAppAssetKind::Scene; assert!(recover_persisted_visual_generation_options( root, &pending, @@ -12546,7 +12473,7 @@ mod canvas_generation_tests { output_path: Some("assets/art-spritesheet.png".to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, slice_count: None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 0a8974342..c88a8a19c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -31,9 +31,9 @@ fn is_direct_platform_art_generation_runtime_context( context: &PlatformArtGenerationRuntimeContext, ) -> bool { let asset_kind = match context.run_id.as_str() { - "art-spec" => "icon-spec", - "game-background" => "game-background", - "art-spritesheet" => "art-spritesheet", + "art-spec" => GameCreationAppAssetKind::IconSpec, + "game-background" => GameCreationAppAssetKind::Scene, + "art-spritesheet" => GameCreationAppAssetKind::IconSpritesheet, _ => return false, }; context.agent_id == "direct-codex-art" @@ -42,7 +42,11 @@ fn is_direct_platform_art_generation_runtime_context( && context.source == "direct-codex" && context.action_id == format!("direct-taonier-{}", context.run_id) && context.action_fingerprint - == format!("direct-taonier-art-v1:{asset_kind}:{}", context.run_id) + == format!( + "direct-taonier-art-v1:{}:{}", + asset_kind.as_str(), + context.run_id + ) } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 78a9cf3d6..a9cb27153 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -396,7 +396,7 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( let inspection_kind = detail .get("inspectionKind") .and_then(serde_json::Value::as_str) - .filter(|value| *value == AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND); + .filter(|value| *value == AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND); let validation_profile = detail .get("validationProfile") .and_then(serde_json::Value::as_str) @@ -408,7 +408,7 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( ) }); let (passed, checks, issues) = if validation_profile.is_some() { - if inspection_kind != Some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND) { + if inspection_kind != Some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND) { return None; } let passed = detail.get("passed")?.as_bool()?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 72cdcb5b6..579e6703b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1136,7 +1136,7 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked( && record .get("inspectionKind") .and_then(serde_json::Value::as_str) - == Some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND) + == Some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND) && record .get("validationProfile") .and_then(serde_json::Value::as_str) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs index bc3236a26..a24f84c3e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs @@ -16,7 +16,7 @@ pub(in crate::agent) fn art_manifest_content() -> String { "status": "generated", "assets": [{ "path": ART_SPRITESHEET_PATH, - "kind": "art-spritesheet", + "kind": GameCreationAppAssetKind::IconSpritesheet.as_str(), "usage": REQUIRED_SLICE_USAGES, }], "sliceManifest": ART_SLICE_MANIFEST_PATH, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 7e17273aa..45700983c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -8,12 +8,12 @@ pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(roo ( "design-foundation", "assets/ui-prototype.png", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, ), ( "art-asset-plan", "assets/art-spritesheet.png", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ), ] .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index d1c01b738..370c90dda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -644,9 +644,9 @@ fn standalone_platform_art_generation_ledger_identity_is_valid( }; let direct_identity_is_valid = if agent_id == "direct-codex-art" { let expected_asset_kind = match run_id { - "art-spec" => Some("icon-spec"), - "game-background" => Some("game-background"), - "art-spritesheet" => Some("art-spritesheet"), + "art-spec" => Some(GameCreationAppAssetKind::IconSpec), + "game-background" => Some(GameCreationAppAssetKind::Scene), + "art-spritesheet" => Some(GameCreationAppAssetKind::IconSpritesheet), _ => None, }; expected_asset_kind.is_some_and(|asset_kind| { @@ -655,7 +655,9 @@ fn standalone_platform_art_generation_ledger_identity_is_valid( && string_field("source") == Some("direct-codex") && string_field("actionId") == Some(format!("direct-taonier-{run_id}").as_str()) && string_field("actionFingerprint") - == Some(format!("direct-taonier-art-v1:{asset_kind}:{run_id}").as_str()) + == Some( + format!("direct-taonier-art-v1:{}:{run_id}", asset_kind.as_str()).as_str(), + ) }) } else { false @@ -1706,7 +1708,7 @@ mod orphaned_external_generation_recovery_tests { write_complete_stage(serde_json::json!({ "prompt": "Direct art spec", "kind": "spec", - "assetKind": "game-background", + "assetKind": "scene", "projectId": "canvas-project", "assetFolderId": "canvas-assets", "generationInputs": { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 41235d21e..2bbc26736 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -71,9 +71,8 @@ pub(crate) use isolated_joins::{ pub(crate) use media::observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test; #[allow(unused_imports)] pub(crate) use media::{ - AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, - AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, }; pub(crate) use policy::{ fail_closed_agent_runtime_confirmation_for_run, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index d07a5735d..10b15c630 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -8,34 +8,15 @@ pub(in crate::agent) struct AgentRuntimeImageInspectInput { pub(in crate::agent) question: Option, } -pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND: &str = "ui-prototype"; +pub(crate) const AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND: &str = "ui-design"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_PATH: &str = "assets/ui-prototype.png"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE: &str = "ui-prototype.v1"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE: &str = "ui-prototype.v2"; pub(crate) const AGENT_RUNTIME_ART_SPEC_PATH: &str = "assets/art-spec.png"; pub(crate) const AGENT_RUNTIME_ART_SPRITESHEET_PATH: &str = "assets/art-spritesheet.png"; -fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: &str) -> bool { - let parsed = match asset_kind { - // 仅用于恢复旧的生成账本;新工具 schema 只暴露 canonical enum 值。 - "game-art" => GameCreationAppAssetKind::Image, - "ui-prototype" => GameCreationAppAssetKind::UiDesign, - "art-spritesheet" => GameCreationAppAssetKind::IconSpritesheet, - _ => GameCreationAppAssetKind::parse_with_context(asset_kind, "canvas.asset_generate"), - }; - AGENT_RUNTIME_CANVAS_ASSET_KINDS.contains(&parsed) -} - -fn canvas_asset_kind_generation_value(asset_kind: &str) -> Option<&'static str> { - match asset_kind.trim() { - // 新工具输入使用 GameCreationAppAssetKind 的 canonical 值;生成适配器内部仍 - // 使用平台请求所需的固定值,直到请求边界完成迁移。 - "image" | "game-art" => Some("game-art"), - "icon-spec" => Some("icon-spec"), - "ui-design" | "ui-prototype" => Some("ui-prototype"), - "icon-spritesheet" | "art-spritesheet" => Some("art-spritesheet"), - _ => None, - } +fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: GameCreationAppAssetKind) -> bool { + AGENT_RUNTIME_CANVAS_ASSET_KINDS.contains(&asset_kind) } pub(in crate::agent) fn design_foundation_ui_page_output_path_is_valid(path: &str) -> bool { @@ -489,7 +470,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect( "images": image_metadata, "responseId": response_id, "conclusionChars": conclusion_chars, - "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND), + "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND), "validationProfile": validation_profile, "passed": passed, "checks": checks, @@ -508,7 +489,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect( "responseId": response_id, "conclusionChars": conclusion_chars, "conclusion": conclusion, - "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND), + "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND), "validationProfile": validation_profile, "passed": passed, "checks": checks, @@ -563,9 +544,10 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio let aspect_ratio = agent_runtime_tool_input_text(input, &["aspectRatio", "aspect_ratio"]); let image_size = agent_runtime_tool_input_text(input, &["imageSize", "image_size"]); let requested_asset_kind = agent_runtime_tool_input_text(input, &["assetKind", "asset_kind"]); - let asset_kind = canvas_asset_kind_generation_value(&requested_asset_kind) - .map(str::to_string) - .unwrap_or(requested_asset_kind); + let asset_kind = GameCreationAppAssetKind::parse_with_context( + &requested_asset_kind, + "canvas.asset_generate", + ); let asset_label = agent_runtime_tool_input_text(input, &["assetLabel", "asset_label"]); let replace_existing = input .get("replaceExisting") @@ -720,12 +702,12 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }; } // 切分模式没有默认值:图集必须显式声明,且声明必须与 assetKind 和网格参数自洽。 - if options.asset_kind == "art-spritesheet" { + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { if options.slice_mode.is_none() { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), - summary: "assetKind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components" + summary: "assetKind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components" .to_string(), detail: None, }; @@ -760,13 +742,13 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), summary: format!( - "sliceMode/gridX/gridY/sliceCount 仅对 assetKind=art-spritesheet 生效,当前 assetKind={}", + "sliceMode/gridX/gridY/sliceCount 仅对 assetKind=icon-spritesheet 生效,当前 assetKind={}", options.asset_kind ), detail: None, }; } - if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) { + if !agent_runtime_canvas_asset_kind_is_supported(options.asset_kind) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), @@ -960,7 +942,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio ); } }; - let committed = if options.asset_kind == "art-spritesheet" { + let committed = if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { let output_path = options .output_path @@ -1244,22 +1226,11 @@ mod platform_art_generation_observation_tests { #[test] fn canvas_asset_kind_validation_accepts_shared_catalog() { for asset_kind in AGENT_RUNTIME_CANVAS_ASSET_KINDS { - assert!(agent_runtime_canvas_asset_kind_is_supported( - asset_kind.as_str() - )); + assert!(agent_runtime_canvas_asset_kind_is_supported(*asset_kind)); } - for legacy_kind in ["game-art", "ui-prototype", "art-spritesheet"] { - assert!(agent_runtime_canvas_asset_kind_is_supported(legacy_kind)); - } - assert_eq!( - canvas_asset_kind_generation_value("image"), - Some("game-art") - ); - assert_eq!( - canvas_asset_kind_generation_value("ui-design"), - Some("ui-prototype") - ); - assert!(!agent_runtime_canvas_asset_kind_is_supported("unsupported")); + assert!(!agent_runtime_canvas_asset_kind_is_supported( + GameCreationAppAssetKind::Unknown + )); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index b4fe86bff..adc7cef6e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1036,10 +1036,10 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=art-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。" } "ui.workflow.run" => { - "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" + "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" } "cocos.editor.execute" => { "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。" @@ -1321,7 +1321,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "assetKind": { "type": ["string", "null"], "enum": asset_kinds }, "assetLabel": { "type": ["string", "null"], "maxLength": 80 }, "replaceExisting": { "type": "boolean" }, - "sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=art-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" }, + "sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=icon-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" }, "gridX": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, "gridY": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, "sliceCount": { "type": ["integer", "null"], "minimum": 1, "maximum": 256, "description": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数" } diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index d77808aea..f74a37d29 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -30,6 +30,7 @@ use crate::agent::{ }; use crate::commands::prepare_local_project_asset_generation; use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project}; +use shared_contracts::game_creation_app::GameCreationAppAssetKind; pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = @@ -69,7 +70,7 @@ const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = pub(crate) struct AssetGenerationTaskRecord { pub(crate) task_id: String, pub(crate) project_id: String, - pub(crate) kind: String, + pub(crate) kind: GameCreationAppAssetKind, pub(crate) asset_name: String, pub(crate) status: String, /// 阶段文案:**由后端拥有**,前端只渲染。 @@ -297,14 +298,14 @@ pub(crate) fn begin_local_project_asset_generation_task( root: &Path, project_id: &str, task_id: &str, - task_kind: &str, + task_kind: GameCreationAppAssetKind, asset_name: &str, output_path: Option<&str>, ) -> Result { let record = AssetGenerationTaskRecord { task_id: task_id.to_string(), project_id: project_id.trim().to_string(), - kind: task_kind.to_string(), + kind: task_kind, asset_name: asset_name.to_string(), status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), @@ -409,7 +410,7 @@ pub(crate) async fn start_local_project_asset_generation( &request.root, &project_id, &task_id, - &asset_kind, + asset_kind, &asset_label, request.options.output_path.as_deref(), )?; @@ -516,7 +517,7 @@ mod asset_generation_task_tests { root, "project-1", task_id, - "image", + GameCreationAppAssetKind::Image, "AI 图", None, ) @@ -532,7 +533,7 @@ mod asset_generation_task_tests { root, "project-1", task_id, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "图标规范", Some(output_path), ) @@ -707,7 +708,7 @@ mod asset_generation_task_tests { &root, "project-1", "task-dup", - "image", + GameCreationAppAssetKind::Image, "AI 图", None, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index a1d4b4675..e7db0dc7e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -536,7 +536,7 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAsse /// /// 三条外部登记边界必须同口径:平台导入(`commands::imported_platform_asset_kind`)、 /// 画板导入(`sync_canvas_project_assets_at`)、外部 `register_local_asset`。 -/// 只做严格等值匹配——不 trim 归一、不查别名、不做迁移。 +/// 只做严格等值匹配,认不出时收口为 `Unknown`。 pub(crate) fn registration_asset_kind( value: &str, context: &'static str, @@ -1787,9 +1787,7 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result { /// 画板导出图层推断出的资源 kind。 /// -/// 直接返回正式枚举成员:这个值会被 `register_local_asset_entry` 原样写入 manifest, -/// 并据以派生落盘 `category`。分支字面量写成非 canonical 值在这里已经无法表达, -/// 不需要别名表兜底。 +/// 直接返回正式枚举成员;该值会被登记边界原样写入 manifest。 pub(crate) fn infer_canvas_export_asset_kind( layer: &CanvasExportLayerMetadata, file: &str, @@ -1924,7 +1922,7 @@ fn register_local_asset_entry_with_change( || existing.source != source; if existing.kind != kind { existing.kind = kind; - existing.category = game_creation_app_asset_category_for_kind(kind.as_str()); + existing.category = game_creation_app_asset_category_for_kind(kind); } existing.media_type = media_type.to_string(); existing.source = source; @@ -1942,7 +1940,7 @@ fn register_local_asset_entry_with_change( local_path: normalized_path.clone(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind(kind.as_str()), + category: game_creation_app_asset_category_for_kind(kind), tags: Vec::new(), source, }); @@ -2186,10 +2184,6 @@ mod tests { } /// 画板导出推断出的 kind 必须已经是 canonical 值。 - /// - /// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值 - /// (历史上曾写 `ui` / `animation` / `asset`),就只能靠别名表兜底,等于同时维护 - /// 两套词汇,且已落盘的 category 无法随别名修复自愈。这里逐分支钉死。 #[test] fn canvas_export_asset_kind_is_always_canonical() { fn layer(layer_type: &str) -> CanvasExportLayerMetadata { @@ -2234,60 +2228,52 @@ mod tests { infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"), GameCreationAppAssetKind::Audio ); - // 兜底分支是 image 而不是旧的 "asset"。 + // 无匹配时使用中性的 image。 assert_eq!( infer_canvas_export_asset_kind(&layer("图层"), "layer.png"), GameCreationAppAssetKind::Image ); - // 所有分支的返回值都必须是正式枚举成员,且不能是旧的非 canonical 写法。 + // 所有分支的返回值都必须是正式枚举成员。 for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] { let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png"); assert!( GameCreationAppAssetKind::ALL.contains(&kind), "非 canonical kind: {kind}(layer_type={layer_type})" ); - for legacy in ["ui", "animation", "asset"] { - assert_ne!(kind, legacy, "写回了非 canonical 的 {legacy}"); - } } } - /// 上传素材的 `kind` 只由内容证据推导:既不能写 `uploaded`(来源词当类型), - /// 更不能把 `ui` 当图片默认值(`ui → ui-design → ui-interaction` 会把任意上传图片 - /// 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来)。 - /// - /// 变异验证:把 `uploaded_asset_kind` 改回返回常量(`"uploaded"` 或 `"ui"`)必须让本用例变红。 #[test] - fn uploaded_asset_kind_uses_content_evidence_and_never_ui() { - assert_eq!(uploaded_asset_kind("hero.png", "image/png"), "image"); - assert_eq!(uploaded_asset_kind("../角色.png", "image/png"), "image"); - assert_eq!(uploaded_asset_kind("bg.webp", ""), "image"); - assert_eq!(uploaded_asset_kind("theme.mp3", "audio/mpeg"), "audio"); - assert_eq!(uploaded_asset_kind("intro.mp4", ""), "video"); - assert_eq!(uploaded_asset_kind("rules.md", "text/markdown"), "document"); - assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "font"); - assert_eq!(uploaded_asset_kind("game.js", "text/javascript"), "code"); - // 判不出内容类型 → `unknown`(派生 `unclassified` → 「待归类」),不猜具体类型。 + fn uploaded_asset_kind_uses_content_evidence() { + assert_eq!( + uploaded_asset_kind("hero.png", "image/png"), + GameCreationAppAssetKind::Image + ); + assert_eq!( + uploaded_asset_kind("theme.mp3", "audio/mpeg"), + GameCreationAppAssetKind::Audio + ); + assert_eq!( + uploaded_asset_kind("intro.mp4", ""), + GameCreationAppAssetKind::Video + ); + assert_eq!( + uploaded_asset_kind("rules.md", "text/markdown"), + GameCreationAppAssetKind::Document + ); + assert_eq!( + uploaded_asset_kind("ui-font.ttf", "font/ttf"), + GameCreationAppAssetKind::Font + ); + assert_eq!( + uploaded_asset_kind("game.js", "text/javascript"), + GameCreationAppAssetKind::Code + ); assert_eq!( uploaded_asset_kind("unknown.bin", "application/octet-stream"), - "unknown" + GameCreationAppAssetKind::Unknown ); - assert_eq!(uploaded_asset_kind("no-extension", ""), "unknown"); - // 反查:正文里的"来源词"和"UI 默认值"都不得出现在返回值里。 - for (file_name, media_type) in [ - ("hero.png", "image/png"), - ("theme.mp3", "audio/mpeg"), - ("unknown.bin", ""), - ] { - let kind = uploaded_asset_kind(file_name, media_type); - assert_ne!(kind, "uploaded", "{file_name} 写回了来源词"); - assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`"); - assert!( - GameCreationAppAssetKind::ALL.contains(&kind), - "非 canonical kind: {kind}({file_name})" - ); - } } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 67eea7239..19e0ce650 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2942,7 +2942,7 @@ mod ui_editor_font_tests { local_path: relative_path.to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("font"), + category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Font), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Uploaded, @@ -3131,9 +3131,7 @@ mod agent_asset_import_tests { assert!(first.assets[4].local_path.ends_with(".html")); assert!(root.join(&first.assets[1].local_path).is_file()); // P0 回归:本地导入的图片只能落中性的 `image`(→ `unclassified` → 「待归类」)。 - // 曾经写成 `ui`,经 `ui → ui-design → ui-interaction` 把任意图片钉死在「UI 交互」栏, - // 且落盘 `category` 成了非 `unclassified` 值后读时自愈永远救不回来。 - // 把 `agent_local_project_file_type` 的图片分支改回 `"ui"` 必须让本用例变红。 + // 不能把任意图片钉死在「UI 交互」栏,落盘 category 也必须保持中性。 let manifest: serde_json::Value = serde_json::from_str( &fs::read_to_string(root.join(".agent/manifest.json")).expect("read imported manifest"), ) @@ -3151,8 +3149,8 @@ mod agent_asset_import_tests { assert!( !imported_assets .iter() - .any(|asset| asset["kind"] == "ui" || asset["category"] == "ui-interaction"), - "本地导入不得产出 `ui` / `ui-interaction`:{manifest}" + .any(|asset| asset["category"] == "ui-interaction"), + "本地导入不得产出 `ui-interaction`:{manifest}" ); let revision_after_first = read_game_creator_agent_runtime_project_revision(root) .expect("read imported revision") @@ -3373,12 +3371,10 @@ mod agent_asset_import_tests { } /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**只接受 canonical 值**, - /// 认不出的原值收口成 `unknown`(→ 「待归类」)并留痕原始串,**永远不许回退成常量 `ui`**。 + /// 认不出的原值收口成 `unknown`(→ 「待归类」)并留痕原始串。 + /// 缺失/空白退回中性的 `image`。 /// - /// kind 没有别名表:平台侧写 `"UI"` / `"ui"` / `"mystery"` 时判据是"这不是合法 kind", - /// 而不是"帮它归一成 `ui-design`";缺失/空白才退回中性的 `image`。 - /// - /// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回 `"ui"` 的实现, + /// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回固定值的实现, /// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。 #[test] fn imported_platform_asset_kind_prefers_payload_type_over_ui_default() { @@ -3404,17 +3400,7 @@ mod agent_asset_import_tests { // 缺失 / 空白退回中性的 `image`(→ 「待归类」)。 assert_eq!(imported_platform_asset_kind(None), Kind::Image); assert_eq!(imported_platform_asset_kind(Some(" ")), Kind::Image); - // 非 canonical 原值(含大小写变体、旧别名与原型链键)一律收口成 `unknown`: - // 判据落在「这不是合法 kind」,原值只出现在留痕日志里。 - for raw in [ - "UI", - "ui", - "mystery", - "game-art", - "ui-prototype", - "__proto__", - "constructor", - ] { + for raw in ["UI", "mystery", "future-kind"] { assert_eq!( imported_platform_asset_kind(Some(raw)), Kind::Unknown, @@ -4438,21 +4424,12 @@ pub(crate) fn import_local_project_assets_for_agent( Ok(RemoteImportResult { assets: imported }) } -/// 平台素材导入落盘的 manifest `kind`:**有真实类型就用真实类型**,判不出才退回中性的 -/// `image`(派生 `unclassified` → 「待归类」),认不出的原值落 `unknown`(同样「待归类」) -/// 并在日志里留下原始串。 -/// -/// 这里绝不允许回退成 `ui`:账户素材库记录与网页项目画布响应本来就带 `assetKind` -/// (`AgentEditorAssetRecord::asset_kind` / 响应字段 `assetKind`),把它换成常量等于丢掉 -/// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏 -/// (`ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值, -/// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。 -/// -/// kind 只有一条口径:严格解析,不做别名归一、不做迁移(`Kind.ALL` 是唯一词汇表)。 +/// 平台素材导入落盘的 manifest `kind`:缺失时使用中性的 `image`,其它输入只走共享 +/// `GameCreationAppAssetKind` 的严格解析,无法识别则得到 `Unknown` 并保留原始值留痕。 fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind { // 平台是外部系统,但 kind 口径**只有一条**:严格解析 + 非 canonical 原值留痕 // (回调已由壳层注册成 `app_log!`,见 `main.rs`);缺失 / 空白按「没有信息」落 - // 中性 `image`。这里不再做别名收口,认不出的值就是 `Unknown` → 「待归类」, + // 中性 `image`。认不出的值就是 `Unknown` → 「待归类」, // 原值只出现在日志里。 platform_kind .map(|kind| crate::assets::registration_asset_kind(kind, "platform.asset_kind")) @@ -4921,7 +4898,7 @@ pub(crate) fn prepare_local_project_asset_generation( "1K", "图片尺寸", )?, - asset_kind: asset_kind.to_string(), + asset_kind, asset_label: local_project_asset_single_line( asset_name, LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS, @@ -4930,9 +4907,9 @@ pub(crate) fn prepare_local_project_asset_generation( .unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()), replace_existing: false, slice_count: None, - // 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 art-spritesheet + // 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 icon-spritesheet // 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。 - slice_mode: (asset_kind == "art-spritesheet") + slice_mode: (asset_kind == GameCreationAppAssetKind::IconSpritesheet) .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, @@ -4993,17 +4970,14 @@ mod local_project_asset_generation_tests { for kind in [ "image", "character", - "spec", "icon-spec", - "ui-prototype", - "art-spritesheet", + "ui-design", + "icon-spritesheet", ] { let request = prepare(kind, "像素月光厨房主角").expect("toolbar kind is supported"); assert_eq!(request.root, PathBuf::from("/tmp/project")); assert_eq!(request.prompt, "像素月光厨房主角"); - // `spec` 只放行到权威类型;其余 kind 原样进入参数化通道。 - let expected = if kind == "spec" { "icon-spec" } else { kind }; - assert_eq!(request.options.asset_kind, expected); + assert_eq!(request.options.asset_kind.as_str(), kind); assert!(!request.options.replace_existing); assert_eq!(request.options.slice_count, None); } @@ -5019,7 +4993,7 @@ mod local_project_asset_generation_tests { let explicit = prepare_local_project_asset_generation( " /tmp/project ", - " art-spritesheet ", + "icon-spritesheet", " 像素图集 ", Some("16:9"), Some("2K"), @@ -5029,7 +5003,10 @@ mod local_project_asset_generation_tests { .expect("explicit options"); assert_eq!(explicit.root, PathBuf::from("/tmp/project")); assert_eq!(explicit.prompt, "像素图集"); - assert_eq!(explicit.options.asset_kind, "art-spritesheet"); + assert_eq!( + explicit.options.asset_kind, + GameCreationAppAssetKind::IconSpritesheet + ); assert_eq!(explicit.options.aspect_ratio, "16:9"); assert_eq!(explicit.options.image_size, "2K"); assert_eq!(explicit.options.asset_label, "主角图集"); @@ -5061,12 +5038,12 @@ mod local_project_asset_generation_tests { "生成要求不能为空" ); assert_eq!( - prepare("game-art", "要求").expect_err("unverified kind"), - "素材类型不受支持:game-art" + prepare("future-kind", "要求").expect_err("unverified kind"), + "素材类型不受支持:future-kind" ); assert_eq!( prepare( - "spec", + "icon-spec", &"x".repeat(LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS + 1) ) .expect_err("oversized prompt"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 55de72c83..20a348583 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -73,10 +73,10 @@ macro_rules! app_log { /// /// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, /// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, -/// 以便回查"还有谁在写 legacy kind"。kind 本身只接受严格值,不做别名、不做迁移。 +/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。 /// /// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; -/// 去重后"有哪些 legacy 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 +/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 /// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。 fn register_non_canonical_asset_kind_reporter() { static REPORTED: std::sync::OnceLock>> = diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 9ac26bd59..8ca8f7b9a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1184,7 +1184,7 @@ pub(crate) fn validate_manifest_required_visual_asset( &art_spec.id, &format!("{:x}", Sha256::digest(&art_spec_bytes)), &art_spec.media_type, - &art_spec.kind, + art_spec.kind.as_str(), )?; external_editor_remote_reference_matches_local_source_at( root, @@ -1947,13 +1947,13 @@ pub(crate) fn read_manifest(path: &Path) -> Result>> = OnceLock::new(); for (raw_kind, asset_id, local_path) in non_canonical_manifest_asset_kinds(payload) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index f82135553..206fcaf2e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -185,7 +185,7 @@ fn successful_first_playable_registration_creates_one_initial_version_with_asset local_path: "assets/player.png".to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("character"), + category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Character), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -458,39 +458,3 @@ fn manifest_read_and_write_reject_symbolic_links() { fs::remove_dir_all(root).ok(); } - -/// 严格解析下旧 kind 会收口成 `unknown`,原始串只能从读到的 JSON 里回捞: -/// 这条用例钉住"回捞哪些条目、跳过哪些",也就是留痕日志的内容边界。 -#[test] -fn non_canonical_manifest_asset_kinds_report_raw_values_only() { - let payload = r#"{ - "assets": [ - { "id": "legacy-ui", "kind": "UI", "localPath": "ui/UI 设计 1.json" }, - { "id": "canonical-image", "kind": "image", "localPath": "assets/a.png" }, - { "id": "already-unknown", "kind": "unknown", "localPath": "assets/b.bin" }, - { - "id": "legacy-slice", - "kind": "art-spritesheet-slice", - "localPath": "assets/art-spritesheet-slices/1.png" - } - ] - }"#; - assert_eq!( - non_canonical_manifest_asset_kinds(payload), - vec![ - ( - "UI".to_string(), - "legacy-ui".to_string(), - "ui/UI 设计 1.json".to_string() - ), - ( - "art-spritesheet-slice".to_string(), - "legacy-slice".to_string(), - "assets/art-spritesheet-slices/1.png".to_string() - ), - ] - ); - // 结构不是预期形状(或不是 JSON)时不报错、不留痕:读取路径自己会给出失败原因。 - assert!(non_canonical_manifest_asset_kinds("not json").is_empty()); - assert!(non_canonical_manifest_asset_kinds(r#"{"assets":{}}"#).is_empty()); -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs index 5221396f2..88f18ef86 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -631,6 +631,7 @@ pub(crate) fn read_project_resource_graph_at( #[cfg(test)] mod tests { use super::*; + use shared_contracts::game_creation_app::game_creation_app_asset_category_for_raw_kind; fn resource( resource_id: &str, @@ -657,7 +658,7 @@ mod tests { local_path: format!("assets/{id}.png"), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("test"), + category: game_creation_app_asset_category_for_raw_kind("test", "test.resource-kind"), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index d1a607495..0e22209dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -2157,7 +2157,7 @@ async fn ensure_resource_edit_source_reference( local_asset_id, &source.source_sha256, &source.media_type, - source.asset_kind.as_str(), + source.asset_kind.manifest_kind().as_str(), )?; let principal_key = external_editor_project_binding_key_sha256(&input.expected_project_id, principal)?; @@ -4015,7 +4015,7 @@ pub(crate) fn normalize_local_project_raster_resource_at( local_path: source_path, image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind(source_kind.as_str()), + category: game_creation_app_asset_category_for_kind(source_kind), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -4260,7 +4260,7 @@ fn commit_resource_edit_asset_internal( } else { source.asset_kind.manifest_kind() }; - let category = game_creation_app_asset_category_for_kind(asset_kind.as_str()); + let category = game_creation_app_asset_category_for_kind(asset_kind); let asset = GameCreationAppAssetManifestEntry { id: asset_id.clone(), kind: asset_kind, @@ -4660,7 +4660,7 @@ fn write_resource_edit_result_binding( &asset.id, &sha256_hex(&bytes), &asset.media_type, - &asset.kind, + asset.kind.as_str(), )?; let binding = new_external_editor_resource_binding( &input.expected_project_id, @@ -5766,7 +5766,7 @@ mod tests { "details": { "provider": "editor-image-edit", "message": "当前素材类型不支持图片快速编辑", - "assetKind": "art-spritesheet", + "assetKind": "icon-spritesheet", "mediaType": "image", }, } @@ -5775,7 +5775,7 @@ mod tests { .as_str(), ), Some( - "当前素材类型不支持图片快速编辑|provider editor-image-edit|素材类型 art-spritesheet|媒体类型 image" + "当前素材类型不支持图片快速编辑|provider editor-image-edit|素材类型 icon-spritesheet|媒体类型 image" .to_string() ), "details.message 必须优先于通用 error.message,并附上 provider 与类型上下文" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs index b6802ed0f..9da8b13de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs @@ -207,16 +207,15 @@ fn version_resource_size_spec_equal( /// 三项兼容性判据(后端是权威判据,前端只做呈现)。 /// /// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口); -/// - `subtypeEqual`:`kind` 严格相等(kind 没有别名表,非 canonical 原值一律收口成 -/// `unknown`)——以上两项是硬门禁; +/// - `subtypeEqual`:`kind` 严格相等(非 canonical 原值一律收口成 `unknown`)——以上两项是硬门禁; /// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`],只作提示。 fn version_resource_compatibility( source: &GameCreationAppAssetManifestEntry, replacement: &GameCreationAppAssetManifestEntry, ) -> ProjectVersionResourceCompatibility { ProjectVersionResourceCompatibility { - category_equal: game_creation_app_asset_effective_category(&source.kind, source.category) - == game_creation_app_asset_effective_category(&replacement.kind, replacement.category), + category_equal: game_creation_app_asset_effective_category(source.kind, source.category) + == game_creation_app_asset_effective_category(replacement.kind, replacement.category), subtype_equal: source.kind == replacement.kind, size_spec_equal: version_resource_size_spec_equal(source, replacement), } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 943019630..345c4f064 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1590,7 +1590,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r &serde_json::json!({ "prompt": "生成原创晶体与潮汐构装体图集", "outputPath": "assets/art-spritesheet.png", - "assetKind": "art-spritesheet", + "assetKind": "icon-spritesheet", "sliceMode": "connected-components", "replaceExisting": true }), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 8b2644286..39314901e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -748,7 +748,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "outputPath": "assets/art-spritesheet.png", "aspectRatio": "1:1", "imageSize": "1K", - "assetKind": "art-spritesheet", + "assetKind": "icon-spritesheet", "assetLabel": "游戏首版核心美术素材", "replaceExisting": false, "sliceMode": "grid", @@ -1083,7 +1083,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { &spec_config_dir, PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spec.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "游戏统一视觉规范图".to_string(), ..PlatformArtAssetGenerationOptions::default() }, @@ -1107,7 +1107,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { output_path: Some("assets/ui-prototype.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), + asset_kind: GameCreationAppAssetKind::UiDesign, asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, @@ -1271,7 +1271,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m "生成首版花园素材", &PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, asset_label: "游戏首版核心美术素材".to_string(), slice_mode: Some("connected-components".to_string()), ..PlatformArtAssetGenerationOptions::default() @@ -2006,7 +2006,7 @@ fn upload_local_asset_writes_file_and_manifest_entry() { assert_eq!(manifest["assets"][0]["mediaType"], "image/png"); assert_eq!(manifest["assets"][0]["source"]["kind"], "uploaded"); // 落盘 `kind` 只能由内容证据推导:图片是 `image`(→ `unclassified` → 「待归类」)。 - // 不许写 `uploaded`(来源词当类型),更不许把 `ui` 当图片默认值——那会把任意上传图片 + // 不许写 `uploaded`(来源词当类型),也不能把图片默认归入 UI 交互——那会把任意上传图片 // 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来。 assert_eq!(manifest["assets"][0]["kind"], "image"); assert_eq!(manifest["assets"][0]["category"], "unclassified"); @@ -2033,10 +2033,8 @@ fn upload_local_asset_writes_file_and_manifest_entry() { .as_array() .unwrap() .iter() - .any(|asset| asset["kind"] == "uploaded" - || asset["kind"] == "ui" - || asset["category"] == "ui-interaction"), - "上传登记不得产出 `uploaded` / `ui` / `ui-interaction`:{manifest}" + .any(|asset| asset["kind"] == "uploaded" || asset["category"] == "ui-interaction"), + "上传登记不得产出 `uploaded` / `ui-interaction`:{manifest}" ); fs::remove_dir_all(root).ok(); @@ -2201,13 +2199,12 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() { fs::remove_dir_all(root).ok(); } -/// 写侧 → 分类的端到端口径:现役写入侧直接写出的非 canonical kind 字面量必须落进明确栏目。 +/// 写侧 → 分类的端到端口径:现役写入侧直接写出的 canonical kind 必须落进明确栏目。 /// /// 这里的字面量与写入侧逐字一致:UI 设计资产是 /// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的 /// `register_local_asset_at` 使用 UI 文档 kind/media 常量, /// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。 -/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。 #[test] fn register_local_asset_derives_category_from_real_write_side_kinds() { let root = unique_project_path(); @@ -5636,7 +5633,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { output_path: Some("assets/ui-prototype.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), + asset_kind: GameCreationAppAssetKind::UiDesign, asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, @@ -5692,7 +5689,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, asset_label: "游戏首版核心美术素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 8617620eb..dfa7b09c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -7300,7 +7300,7 @@ fn local_asset_prompt_context_discovers_unregistered_media_without_formal_identi import_canvas_asset_at( &root, "assets/registered.png", - "ui", + "ui-design", "image/png", "canvas-project-registered", Some("resource-registered".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index cb2d7d2ce..2ea327cae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1920,7 +1920,7 @@ fn image_inspect_safe_receipt_keeps_legacy_v1_audit_readable() { "responseId": "resp_legacy_ui_audit", "conclusionChars": 20, "conclusion": "历史 v1 审计只用于读取。", - "inspectionKind": AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, + "inspectionKind": AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, "validationProfile": AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, "passed": true, "checks": { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 326eb1b5e..99478e8b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -115,9 +115,9 @@ pub(super) use crate::{ AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SCHEMA_VERSION, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION, - AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, - AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_PATH, + AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_CONFIG_FILE_NAME, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, GAME_CREATOR_USER_INPUT_REQUEST_TOOL, + PROJECT_BLACKBOARD_MEMORY_PATH, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs index 562a12a52..e827ea75d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs @@ -856,10 +856,8 @@ fn ensure_page_ui_resource( /// 比较 sprite 资源身份时忽略 `metadata.asset_type`。 /// -/// `asset_type` 是随 canonical kind 派生的展示串(`SpriteAssetMetadata` 参与 `PartialEq`), -/// 而迁移前的 State 里存的是当时的 manifest 原始 kind(`ui-prototype` / `art-spritesheet` 等)。 -/// 它参与比较会让"对既有 State 重跑工作流"误报「与已有 State 资源冲突」,所以身份判定只看 -/// 真正影响渲染的字段;展示串随 kind 派生、不构成资源身份。 +/// `asset_type` 是随 canonical kind 派生的展示串,不构成资源身份;身份判定只看真正影响 +/// 渲染的字段,避免展示字段变化造成资源冲突。 fn same_sprite_asset_identity(current: &SpriteAsset, next: &SpriteAsset) -> bool { if current.metadata.asset_type == next.metadata.asset_type { return current == next; diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index bfd4cb80d..e6db6bacf 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -18,12 +18,14 @@ import { type GameCreationAgentCapabilityDescriptor, type GameCreationAgentRunTrace, type GameCreationAgentToolCallTrace, + type GameCreationAppAssetKind, type GameCreationAppCommandDescriptor, type GameCreationAppLimitedRunCommandDescriptor, type GameCreationAppManifest, type GameCreationAppPermission, type GameCreationAppPreviewState, type GameCreationAppPreviewStatus, + parseGameCreationAppAssetKind, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD, @@ -1391,7 +1393,7 @@ export function App({ UploadLocalAssetResult[] >([]); const [assetLocalPath, setAssetLocalPath] = useState('assets/hero.png'); - const [assetKind, setAssetKind] = useState('asset'); + const [assetKind, setAssetKind] = useState('unknown'); const [assetMediaType, setAssetMediaType] = useState( 'application/octet-stream', ); @@ -5652,7 +5654,7 @@ export function App({ } const [ localPath, - kind = 'asset', + kind = 'unknown', mediaType = 'application/octet-stream', ] = prompt.slice('/asset-register '.length).trim().split(/\s+/); if (!localPath) { @@ -5672,7 +5674,12 @@ export function App({ ]); return; } - queuePendingCommand({ id: 'asset.register', localPath, kind, mediaType }); + queuePendingCommand({ + id: 'asset.register', + localPath, + kind: parseGameCreationAppAssetKind(kind, 'chat.asset-register.kind'), + mediaType, + }); setMessages((current) => [ ...current, { role: 'assistant', text: `准备登记项目资产:${localPath}` }, @@ -6128,7 +6135,10 @@ export function App({ canvasProjectId, canvasAssetId: canvasAssetObjectId ? '' : canvasAssetId, canvasAssetObjectId, - kind: kind ?? 'asset', + kind: parseGameCreationAppAssetKind( + kind ?? 'unknown', + 'chat.canvas-asset-import.kind', + ), mediaType: mediaType ?? 'application/octet-stream', }); setMessages((current) => [ @@ -10393,7 +10403,7 @@ export function App({ void executeAssetRegister( nextProjectPath, localPath, - assetKind, + parseGameCreationAppAssetKind(assetKind, 'ui.asset-register.kind'), assetMediaType, assetSourceKind, canvasProjectId, @@ -10406,7 +10416,7 @@ export function App({ async function executeAssetRegister( nextProjectPath: string, localPath: string, - kind: string, + kind: GameCreationAppAssetKind, mediaType: string, sourceKind: string, canvasProjectId: string, @@ -10533,7 +10543,10 @@ export function App({ void executeCanvasAssetImport( nextProjectPath, localPath, - assetKind, + parseGameCreationAppAssetKind( + assetKind, + 'ui.canvas-asset-import.kind', + ), assetMediaType, canvasProjectId, resourceId, @@ -10546,7 +10559,7 @@ export function App({ async function executeCanvasAssetImport( nextProjectPath: string | null, localPath: string, - kind: string, + kind: GameCreationAppAssetKind, mediaType: string, canvasProjectId: string, resourceId: string, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index dd40ab154..61f1d5fde 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -2,6 +2,7 @@ import type { GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, GameCreationAppAgentGroup, + GameCreationAppAssetKind, GameCreationAppCommandDescriptor, GameCreationAppManifest, GameCreationAppTaskStatus, @@ -856,7 +857,7 @@ export interface LocalProjectFileEntry { export type ProjectAssetDraft = { localPath: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; }; @@ -1201,7 +1202,7 @@ export type PendingCommand = | { id: 'asset.register'; localPath: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; } | { @@ -1263,7 +1264,7 @@ export type PendingCommand = canvasProjectId: string; canvasAssetId: string; canvasAssetObjectId?: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; } | { diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts index 8465959e4..42b5e1754 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts @@ -34,7 +34,7 @@ export type RemoteAssetCandidate = { assetId: string; name: string; objectKey: string; - assetKind: string | null; + assetKind: GameCreationAppAssetKind | null; sizeBytes: number; previewUrl?: string; }; @@ -357,7 +357,13 @@ export function buildRemoteFolderFiles( assetId: asset.assetId || asset.objectKey || asset.imageSrc || fileName, name: baseName, objectKey: asset.objectKey || asset.imageSrc || fileName, - assetKind: asset.assetKind ?? null, + assetKind: + asset.assetKind == null + ? null + : parseGameCreationAppAssetKind( + asset.assetKind, + 'asset-importer.remote.assetKind', + ), sizeBytes: asset.size ?? 0, previewUrl: asset.previewUrl, }; @@ -377,13 +383,7 @@ export function buildRemoteFolderFiles( asset.imageSrc || 'platform-image', localPath, - assetKind: - asset.assetKind == null - ? null - : parseGameCreationAppAssetKind( - asset.assetKind, - 'asset-importer.remote.assetKind', - ), + assetKind: asset.assetKind == null ? null : candidate.assetKind, }, }); } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index eaa6b87e7..3e44948dc 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -1,3 +1,7 @@ +import { + type GameCreationAppAssetKind, + parseGameCreationAppAssetKind, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveResourceCanvasBottomTools, type ResourceCanvasAssetToolAction, @@ -19,7 +23,7 @@ export type ResourceCanvasAssetGenerationTaskStatus = export type LocalProjectAssetGenerationTaskRecord = { taskId: string; projectId: string; - kind: string; + kind: GameCreationAppAssetKind; assetName: string; status: string; phaseDetail: string; @@ -36,7 +40,7 @@ export type ResourceCanvasAssetGenerationTask = { taskId: string; actionId: string; actionLabel: string; - assetKind: string; + assetKind: GameCreationAppAssetKind; assetName: string; prompt: string; aspectRatio: string; @@ -107,7 +111,7 @@ const RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES = [ * 抄第二份就会在加 kind 时漂移。同一 kind 出现在多个入口时取首次出现的那个。 */ export function resourceCanvasAssetGenerationKindLabel( - kind: string, + kind: GameCreationAppAssetKind, ): string | null { for (const category of RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES) { for (const tool of resolveResourceCanvasBottomTools(category)) { @@ -181,12 +185,16 @@ const RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT = 50; export function restoreResourceCanvasAssetGenerationTask( record: LocalProjectAssetGenerationTaskRecord, ): ResourceCanvasAssetGenerationTask { + const assetKind = parseGameCreationAppAssetKind( + record.kind, + 'resource-canvas.generation-task', + ); return { taskId: record.taskId, - actionId: `restored:${record.kind}`, + actionId: `restored:${assetKind}`, actionLabel: - resourceCanvasAssetGenerationKindLabel(record.kind) ?? record.assetName, - assetKind: record.kind, + resourceCanvasAssetGenerationKindLabel(assetKind) ?? record.assetName, + assetKind, assetName: record.assetName, prompt: '', aspectRatio: '', diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts index f0fd3e9c7..fe541a017 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts @@ -1,4 +1,5 @@ import type { + GameCreationAppAssetKind, GameCreationAppAssetManifestEntry, ProjectResourceCanvasCategory, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; @@ -24,17 +25,13 @@ import type { ResourceCanvasGenerationKind } from './resourceCanvasGenerationMod * `start_local_project_asset_generation` 放行的无源生成类型。 * * 与 Rust `PLATFORM_ART_ASSET_GENERATION_KINDS` 一一对应(`publication-material` 属宣发 - * 素材,本轮不做,因此不在这里)。`spec` / `icon-spec` 都会在 Rust 侧收口到 `icon-spec`, - * 但两个入口携带的 `kind` 字符串不同,账本与 manifest 的 `source.generationKind` 也不同, - * 所以这里保留原值,不做前端归并。 + * 素材,本轮不做,因此不在这里)。生成入口的 `assetKind` 只使用共享生成绑定中的 + * canonical manifest kind;动作 id 和 `source.generationKind` 是独立字段。 */ -export type ResourceCanvasGeneratedAssetKind = - | 'image' - | 'character' - | 'spec' - | 'icon-spec' - | 'ui-prototype' - | 'art-spritesheet'; +export type ResourceCanvasGeneratedAssetKind = Extract< + GameCreationAppAssetKind, + 'image' | 'character' | 'icon-spec' | 'ui-design' | 'icon-spritesheet' +>; /** 权威规范图的落点;Rust `AGENT_RUNTIME_ART_SPEC_PATH`。 */ export const RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH = 'assets/art-spec.png'; @@ -195,7 +192,7 @@ const generateSpecCharacterAction: ResourceCanvasAssetToolAction = { id: 'generate-spec-character', route: 'asset', label: '角色规范', - assetKind: 'spec', + assetKind: 'icon-spec', audioKind: null, assetName: '角色规范', promptPlaceholder: '描述角色的外形、服装、动作与画风要求', @@ -210,7 +207,7 @@ const generateSpecCustomAction: ResourceCanvasAssetToolAction = { id: 'generate-spec-custom', route: 'asset', label: '自定义规范', - assetKind: 'spec', + assetKind: 'icon-spec', audioKind: null, assetName: '自定义规范', promptPlaceholder: '描述这张规范图要约束的视觉范围与要求', @@ -240,7 +237,7 @@ const generateIconSpritesheetAction: ResourceCanvasAssetToolAction = { id: 'generate-icon-spritesheet', route: 'asset', label: '生成图标素材', - assetKind: 'art-spritesheet', + assetKind: 'icon-spritesheet', audioKind: null, assetName: 'AI 生成图标素材', promptPlaceholder: '描述需要哪些图标素材,例如:各种敌人头像', @@ -255,7 +252,7 @@ const generateUiPrototypeAction: ResourceCanvasAssetToolAction = { id: 'generate-ui-prototype', route: 'asset', label: '生成 UI 设计图', - assetKind: 'ui-prototype', + assetKind: 'ui-design', audioKind: null, assetName: 'AI 生成 UI 设计图', promptPlaceholder: '描述这张界面要承载的玩法与操作', diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts index c12ac7c02..4cd823766 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts @@ -1,3 +1,4 @@ +import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { LocalProjectResourceEditKind } from '../../view/project-development/resourceEditModel'; /** @@ -9,10 +10,10 @@ import type { LocalProjectResourceEditKind } from '../../view/project-developmen * “当前资源类型不支持无源生成”。因此入口只呈现这三类——宁可少一个入口, * 也不给一个提交必然失败、或者拿图片参数糊弄用户的按钮。 */ -export type ResourceCanvasGenerationKind = - | 'video' - | 'sound-effect' - | 'background-music'; +export type ResourceCanvasGenerationKind = Extract< + GameCreationAppAssetKind, + 'video' | 'sound-effect' | 'background-music' +>; export type ResourceCanvasGenerationOption = { kind: ResourceCanvasGenerationKind; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx index 1bd9ed092..e467da2d4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx @@ -68,7 +68,7 @@ export function ResourceClassificationPanel({ * **写回必须用落盘口径** `gameCreationAppAssetPersistedCategory`,不能用读显示口径 * `gameCreationAppAssetCategory`:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind` * 能派生出明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类 - * (真机上同一条 `kind:"ui"` 资产同时出现过 `unclassified` 与 `ui-interaction` 两种落盘值)。 + * (真机上同一条 UI 设计资产同时出现过 `unclassified` 与 `ui-interaction` 两种落盘值)。 * * 拆出类型面板后这条不变量不再依赖"用户是否碰过控件",而是结构性的: * 本面板没有类型控件,`category` 恒为落盘原值。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts index b0f2d49bd..b30c063d6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts @@ -2,9 +2,11 @@ import { GAME_CREATION_APP_UI_DESIGN_ASSET_KIND, type GameCreationAppAssetCategory, gameCreationAppAssetCategory, + type GameCreationAppAssetKind, gameCreationAppAssetTags, type GameCreationAppManifest, type GameIterationVersion, + isGameCreationAppAssetKind, type ProjectResourceCanvasCategory, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; @@ -134,9 +136,8 @@ export type ProjectedResourceKind = 'audio' | 'art' | 'code' | 'document'; * 1. 第一趟只看 `mediaType` 与路径扩展名——它们是文件内容的可验证证据; * 2. 第二趟才看 `kind`——它是登记时的标签,可能过时、可能宽泛。 * - * 为什么不能把 `kind` 混进第一趟:`artKind` 含 `ui`,而 `ui` 是极常见的登记标签 - * (旧版本把界面素材统一写成 `kind:"ui"`)。一旦 `kind` 与 mediaType 同权, - * `kind:"ui"` + `mediaType:"application/json"` 的界面规格就会被判成 `art`, + * 为什么不能把 `kind` 混进第一趟:登记标签不能覆盖可验证的内容类型。一旦 `kind` 与 + * mediaType 同权,界面规格就会被判成 `art`, * 于是卡面角标按 `art` 派生显示「图片」,而预览调度按 `art` 走图像分支、 * 又因 mediaType 不是图像而兜底成 `placeholder` 且**永不发起读取** —— * 出现「标着图片、却是占位、没有任何报错」的分叉。分趟后 JSON/HTML 这类 @@ -145,7 +146,7 @@ export type ProjectedResourceKind = 'audio' | 'art' | 'code' | 'document'; export function projectedResourceKind(input: { path: string; mediaType: string; - kind?: string; + kind?: GameCreationAppAssetKind; }): ProjectedResourceKind | null { const normalizedPath = input.path.trim().toLowerCase(); const normalizedMediaType = input.mediaType.trim().toLowerCase(); @@ -217,7 +218,9 @@ export function projectResourceDisplayKind( return projectedResourceKind({ path: resource.path, mediaType: resource.mediaType, - kind: resource.subtype, + kind: isGameCreationAppAssetKind(resource.subtype) + ? resource.subtype + : undefined, }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 1ffd3dd66..235e642c4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -11747,7 +11747,7 @@ export function registerProjectAgentStatusTests() { prompt: '各种敌人头像:骷髅 哥布林 强盗', }); expect(generateCall(calls, 'art-spritesheet')).toMatchObject({ - kind: 'art-spritesheet', + kind: 'icon-spritesheet', prompt: '各种敌人头像:骷髅 哥布林 强盗', aspectRatio: '1:1', imageSize: '1K', @@ -11760,7 +11760,7 @@ export function registerProjectAgentStatusTests() { prompt: '横屏单屏界面,含 HUD 与操作控件', }); expect(generateCall(calls, 'ui-prototype')).toMatchObject({ - kind: 'ui-prototype', + kind: 'ui-design', prompt: '横屏单屏界面,含 HUD 与操作控件', aspectRatio: '16:9', imageSize: '1K', @@ -12581,7 +12581,7 @@ export function registerProjectAgentStatusTests() { startLocalAsset: async ({ taskId }) => ({ taskId, projectId: 'workbench-submit-late-failure', - kind: 'ui-prototype', + kind: 'ui-design', assetName: '待提交设计图', status: 'running', phaseDetail: '正在生成。', @@ -12629,7 +12629,7 @@ export function registerProjectAgentStatusTests() { startLocalAsset: async ({ taskId }) => ({ taskId, projectId: 'workbench-sidebar-collapsed', - kind: 'ui-prototype', + kind: 'ui-design', assetName: '待提交设计图', status: 'running', phaseDetail: '正在生成。', diff --git a/apps/ai-game-creator-shell/tests/assetImporter.test.ts b/apps/ai-game-creator-shell/tests/assetImporter.test.ts index c35a50a41..80402a7b7 100644 --- a/apps/ai-game-creator-shell/tests/assetImporter.test.ts +++ b/apps/ai-game-creator-shell/tests/assetImporter.test.ts @@ -115,7 +115,7 @@ beforeEach(() => { { id: 'registered-from-disk', localPath: 'assets/uploads/unregistered.png', - assetKind: 'ui', + assetKind: 'ui-design', }, ], }; @@ -212,7 +212,7 @@ describe('AssetImporter incremental import recovery', () => { { id: 'registered-from-disk', localPath: 'assets/uploads/unregistered.png', - assetKind: 'ui', + assetKind: 'ui-design', }, ]); }); diff --git a/apps/ai-game-creator-shell/tests/assetKind.test.ts b/apps/ai-game-creator-shell/tests/assetKind.test.ts index c4949554b..4498c6530 100644 --- a/apps/ai-game-creator-shell/tests/assetKind.test.ts +++ b/apps/ai-game-creator-shell/tests/assetKind.test.ts @@ -14,14 +14,14 @@ describe('shell generated asset kind boundary', () => { expect(GAME_CREATION_APP_ASSET_KINDS).toContain('unknown'); }); - it('does not alias old values and records unknown input', () => { + it('records non-canonical input as unknown', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - expect(parseGameCreationAppAssetKind('ui', 'manifest.asset.kind')).toBe( - 'unknown', - ); + expect( + parseGameCreationAppAssetKind('future-kind', 'manifest.asset.kind'), + ).toBe('unknown'); expect(warn).toHaveBeenCalledWith( '未知的 GameCreationApp 资源 kind,已收口为 unknown', - { rawKind: 'ui', sourceContext: 'manifest.asset.kind' }, + { rawKind: 'future-kind', sourceContext: 'manifest.asset.kind' }, ); warn.mockRestore(); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx index 1b7c0a8d0..ada99bd4e 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx @@ -22,7 +22,7 @@ const uiPrototypeAction: ResourceCanvasAssetToolAction = { id: 'generate-ui-prototype', route: 'asset', label: '生成 UI 设计图', - assetKind: 'ui-prototype', + assetKind: 'ui-design', audioKind: null, assetName: 'AI 生成 UI 设计图', promptPlaceholder: '描述这张界面要承载的玩法与操作', @@ -129,7 +129,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ - kind: 'ui-prototype', + kind: 'ui-design', prompt: '主界面与背包页', assetName: 'AI 生成 UI 设计图', }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 908aa2669..8d7d67098 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -24,7 +24,7 @@ const uiPrototypeAction: ResourceCanvasAssetToolAction = { id: 'generate-ui-prototype', route: 'asset', label: '生成 UI 设计图', - assetKind: 'ui-prototype', + assetKind: 'ui-design', audioKind: null, assetName: 'AI 生成 UI 设计图', promptPlaceholder: '描述这张界面要承载的玩法与操作', @@ -57,7 +57,7 @@ function record( return { taskId, projectId: 'project-1', - kind: 'ui-prototype', + kind: 'ui-design', assetName: `素材 ${taskId}`, status, phaseDetail: status === 'running' ? '正在生成。' : '排队中。', @@ -171,7 +171,7 @@ describe('生成任务模型', () => { }); test('kind 文案从工具栏模型派生,未知 kind 不编造', () => { - expect(resourceCanvasAssetGenerationKindLabel('ui-prototype')).toBe( + expect(resourceCanvasAssetGenerationKindLabel('ui-design')).toBe( '生成 UI 设计图', ); expect(resourceCanvasAssetGenerationKindLabel('image')).toBe('生成图片'); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index 41b20e135..448d639e0 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -23,7 +23,7 @@ const uiPrototypeAction: ResourceCanvasAssetToolAction = { id: 'generate-ui-prototype', route: 'asset', label: '生成 UI 设计图', - assetKind: 'ui-prototype', + assetKind: 'ui-design', audioKind: null, assetName: 'AI 生成 UI 设计图', promptPlaceholder: '描述这张界面要承载的玩法与操作', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index c228db240..f3a54e1b4 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -317,7 +317,7 @@ describe('ResourceCanvasBottomToolbarView', () => { expect.objectContaining({ route: 'asset', label: '角色规范', - assetKind: 'spec', + assetKind: 'icon-spec', }), ); // 选中后菜单收起,不会残留一层挡住画布。 @@ -408,7 +408,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ - kind: 'ui-prototype', + kind: 'ui-design', prompt: '横屏单屏界面', assetName: 'AI 生成 UI 设计图', aspectRatio: '9:16', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index d0884197d..e49d9ee46 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -331,19 +331,19 @@ describe('resource canvas layout model', () => { [ { ...resource('ui-prototype', 'scene'), - subtype: 'ui-prototype', + subtype: 'ui-design', mediaType: 'image/png', label: '甲界面', }, { ...resource('art-spritesheet-b', 'scene'), - subtype: 'art-spritesheet', + subtype: 'icon-spritesheet', mediaType: 'image/png', label: '乙图集', }, { ...resource('art-spritesheet-a', 'scene'), - subtype: 'art-spritesheet', + subtype: 'icon-spritesheet', mediaType: 'image/png', label: '乙图集', }, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts index c130241e6..14d349f43 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts @@ -244,7 +244,7 @@ describe('resource canvas toolbar actions', () => { ['video/mp4', 'video', 'assets/intro.mp4'], ['audio/wav', 'sound-effect', 'assets/hit.wav'], ['audio/mpeg', 'background-music', 'assets/music.mp3'], - ['text/html', 'ui-prototype', 'ui/menu.html'], + ['text/html', 'ui-design', 'ui/menu.html'], ['text/markdown', 'document', 'docs/design.md'], ['application/json', 'other', 'assets/data.json'], ['application/octet-stream', 'other', 'assets/model.bin'], diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts index f205984ed..abf5030af 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts @@ -3,8 +3,8 @@ // 可执行地数出「每张卡走哪个预览分支」「最终落哪个画布栏目」「实际发出哪些 IPC」。 // // 真机数据(只读读出,未修改): -// assets 61 条 = ui/image/png ×49、ui/application/json ×8、art-spritesheet/image/png ×1、 -// game-background/image/png ×1、game-entry/text/html ×1、icon-spec/image/png ×1 +// assets 61 条 = future-ui/image/png ×49、future-ui/application/json ×8、icon-spritesheet/image/png ×1、 +// scene/image/png ×1、future-game-entry/text/html ×1、icon-spec/image/png ×1 // 每条都带显式 category:scene ×1、ui-interaction ×2、unclassified ×58 // versions 5 条 import { describe, expect, test } from 'vitest'; @@ -31,35 +31,35 @@ type Shape = { /** 真机 manifest 的 assets 逐条形状(category 为磁盘上的持久化值)。 */ const REAL_ASSET_SHAPES: readonly Shape[] = [ { - kind: 'ui', + kind: 'future-ui', mediaType: 'image/png', ext: 'png', count: 49, category: 'unclassified', }, { - kind: 'ui', + kind: 'future-ui', mediaType: 'application/json', ext: 'json', count: 8, category: 'unclassified', }, { - kind: 'art-spritesheet', + kind: 'icon-spritesheet', mediaType: 'image/png', ext: 'png', count: 1, category: 'ui-interaction', }, { - kind: 'game-background', + kind: 'scene', mediaType: 'image/png', ext: 'png', count: 1, category: 'scene', }, { - kind: 'game-entry', + kind: 'future-game-entry', mediaType: 'text/html', ext: 'html', count: 1, @@ -118,18 +118,18 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { expect(resources).toHaveLength(61); }); - test('存量 57 条 ui 的落盘 unclassified 停在「待归类」:kind 只接受 canonical,不归一也不迁移', () => { + test('存量 57 条无法解析的 kind 的落盘 unclassified 停在「待归类」', () => { const assets = realManifest().assets; - const uiAssets = assets.filter((asset) => asset.kind === 'ui'); + const uiAssets = assets.filter((asset) => asset.kind === 'future-ui'); // 前置事实:这 57 条落盘值确实是 unclassified(历史误判现场)。 expect(uiAssets).toHaveLength(57); expect(uiAssets.every((asset) => asset.category === 'unclassified')).toBe( true, ); - // `ui` 不是 canonical kind → 收口成 `unknown` → 派生分类仍是 unclassified: + // 无法解析的 kind 收口成 `unknown` → 派生分类仍是 unclassified: // 读时自愈对这类存量**不会触发**,它们继续留在「待归类」,由 `app_log!` 的 - // 原始串留痕去定位写入方(无别名表、无迁移)。 + // 原始串留痕去定位写入方。 expect( uiAssets.every( (asset) => gameCreationAppAssetCategory(asset) === 'unclassified', @@ -155,15 +155,8 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { category: undefined, }), ).toBe('ui-interaction'); - // 非 canonical 原值不查别名表:一律 unknown → unclassified。 - for (const raw of [ - 'ui', - 'UI', - 'ui-prototype', - 'art-spritesheet', - 'game-background', - 'game-entry', - ]) { + // 无法解析的输入统一收口为 unknown → unclassified。 + for (const raw of ['UI', 'future-kind', 'future-ui']) { expect( gameCreationAppAssetCategory({ kind: raw, category: 'unclassified' }), ).toBe('unclassified'); @@ -180,14 +173,14 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { } }); - test('严格口径下:58 条非 canonical kind 停在待归类,明确栏目只由落盘 category 决定', () => { + test('严格口径下:58 条无法解析的 kind 停在待归类,明确栏目只由落盘 category 决定', () => { const resources = projectResourcesFromReadModels(realManifest(), [], []); const byCategory = countBy(resources, (resource) => projectResourceCanvasCategory(resource), ); - // ui ×57、game-entry ×1 的 kind 都认不出 → unknown → unclassified; - // art-spritesheet / game-background 虽然 kind 认不出,但落盘 category 是明确值, + // future-ui ×57、future-game-entry ×1 的 kind 都认不出 → unknown → unclassified; + // canonical 图片 kind 的落盘 category 是明确值, // 读显示口径信任落盘值,因此仍留在各自栏目。 expect(byCategory).toEqual({ unclassified: 58, @@ -195,7 +188,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { scene: 1, }); const uiResources = resources.filter( - (resource) => resource.subtype === 'ui', + (resource) => resource.subtype === 'future-ui', ); expect(uiResources).toHaveLength(57); expect( @@ -216,11 +209,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { })); const byPreviewKind = countBy(previewKindByResource, (e) => e.previewKind); - // 这里曾经是 `placeholder: 8`:8 条 `ui/application/json` 因为 `kind:"UI"` 命中 - // `artKind`(其中含 `ui`)而被判成 art,于是角标显示「图片」,而预览调度按 art 走 - // 图像分支、又因 mediaType 不是图像而兜底成 placeholder —— 卡片永不发起读取, - // 表现为「标着图片却只有占位图标」。类型判定改为 mediaType/扩展名优先、kind 只做 - // 兜底后,它们进入文本读取通道;卡面再消费原生识别结果显示 UI 设计或 JSON。 + // 文档资源按 mediaType/扩展名进入文本读取通道,卡面再消费原生识别结果。 expect(byPreviewKind).toEqual({ 'raster-image': 52, document: 8, @@ -235,7 +224,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { 'application/json': 8, }); expect(countBy(documentAssets, (e) => e.resource.subtype)).toEqual({ - ui: 8, + 'future-ui': 8, }); // 角标与预览分支必须是同一套口径:标「图片」的必须真的走图片分支。 for (const entry of previewKindByResource) { diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx index 6864353fb..3c1a837ab 100644 --- a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx @@ -238,7 +238,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => { /** * 漂移防线:落盘 `unclassified` 而 `kind` 能派生出明确分类时,读显示口径会自愈成 * `ui-interaction`,但**回写必须是落盘原值** `unclassified`。否则「编辑标签」面板 - * 会在用户只改标签时静默改掉分类——这正是真机上同一条 `kind:"ui"` 资产同时出现 + * 会在用户只改标签时静默改掉分类——这正是真机上同一条 UI 设计资产同时出现 * `unclassified` 与 `ui-interaction` 两种落盘值的成因。 * * 变异验证:把面板改回 `gameCreationAppAssetCategory(asset)`,本用例必须失败。 @@ -477,7 +477,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => { describe('ResourceClassificationPanel 不再编辑素材类型', () => { /** * 落盘 `unclassified` 而 canonical `kind:"ui-design"` 能派生出 `ui-interaction`:这是读时 - * 自愈的触发条件,也正是"显示值 ≠ 落盘值"的现场。kind 只接受 canonical 值——写 `"ui"` + * 自愈的触发条件,也正是"显示值 ≠ 落盘值"的现场。kind 只接受 canonical 值——无法解析时 * 会被收口成 `unknown`,派生仍是 `unclassified`,自愈不触发。 */ const selfHealingAsset: GameCreationAppAssetManifestEntry = { @@ -524,7 +524,7 @@ describe('ResourceClassificationPanel 不再编辑素材类型', () => { /** * 只改标签(本面板**没有**类型控件,用户不可能在这里改类型):载荷 `category` 必须仍是 - * 落盘原值 `unclassified`,即使读显示口径会把这个 `kind:"ui"` 资产自愈成 `ui-interaction`。 + * 落盘原值 `unclassified`,即使读显示口径会把这个 UI 设计资产自愈成 `ui-interaction`。 * * 这是与新面板「选中即落盘 → 载荷是用户选的值」互为对照的关键用例: * 没有它,任何"顺手把显示值写回去"的实现都会绿。 diff --git a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx index 7e04d094e..4b20d3c94 100644 --- a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx @@ -1,6 +1,7 @@ /** @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createGameCreationAppManifest, fireEvent, @@ -30,13 +31,16 @@ const emptyState = { font_assets: {}, }; -async function mountJsonCanvas(kind = 'ui', rejectPreview = false) { +async function mountJsonCanvas( + kind: GameCreationAppAssetKind = 'ui-design', + rejectPreview = false, +) { installResizeObserverStub(); const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试'); manifest.assets = [ { id: 'design', kind, localPath: 'ui/design.json' }, - { id: 'ordinary', kind: 'UI', localPath: 'assets/ordinary.json' }, - { id: 'spoof', kind: 'UI', localPath: 'ui/spoof.json' }, + { id: 'ordinary', kind: 'unknown', localPath: 'assets/ordinary.json' }, + { id: 'spoof', kind: 'unknown', localPath: 'ui/spoof.json' }, ].map((asset) => ({ ...asset, mediaType: 'application/json', @@ -146,7 +150,7 @@ afterEach(() => { }); describe('JSON 画布卡片与入口', () => { - it.each(['ui', 'document'])( + it.each(['ui-design', 'document'] as const)( '合法 State 的 %s 登记显示 UI 设计,进入现有编辑器且不修改 manifest', async (kind) => { const { card, invoke, onManifestChange } = await mountJsonCanvas(kind); @@ -196,7 +200,7 @@ describe('JSON 画布卡片与入口', () => { }); it('JSON 读取失败不授予编辑入口,仍可查看明确的读取错误', async () => { - const { card } = await mountJsonCanvas('UI', true); + const { card } = await mountJsonCanvas('unknown', true); fireEvent.click(card('design').querySelector('button')!); expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '预览' })); diff --git a/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts index ecc685ed7..c23f09f5b 100644 --- a/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts @@ -12,7 +12,7 @@ const resource: ProjectResource = { path: 'ui/design.json', label: '设计', mediaType: 'application/json', - subtype: 'UI', + subtype: 'ui-design', category: 'document', sourceLabel: '', taskTitle: null, @@ -34,7 +34,7 @@ const verified: ProjectResourceCardPreviewState = { }; describe('JSON 卡片呈现', () => { - it.each(['UI', 'ui', 'ui-design', 'document'])( + it.each(['ui-design', 'unknown', 'document'] as const)( '合法识别不依赖 %s 标签', (subtype) => { expect( diff --git a/apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts b/apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts index ad7af2464..2891c0afe 100644 --- a/apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts +++ b/apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts @@ -7,8 +7,8 @@ import { projectResourceTypeLabel } from '../src/view/project-development/resour * 派发决策必须由**可验证的内容证据**(mediaType,缺失时退路径扩展名)决定, * 登记标签 `kind` 只能在两者都给不出结论时兜底。 * - * 这条不变量是本轮「标着图片却只有占位图标」缺陷的直接护栏:当时 `kind:"ui"` 与 - * mediaType 同权,界面规格的 `application/json` 被 `artKind`(含 `ui`)顶成 art, + * 这条不变量是本轮「标着图片却只有占位图标」缺陷的直接护栏:登记标签与 + * mediaType 同权,界面规格的 `application/json` 会被宽泛登记标签顶成 art, * 于是角标按 art 显示「图片」,而预览调度走图像分支、又因 mediaType 不是图像兜底成 * `placeholder` 且**永不发起读取** —— 卡片既不报错也没有图。 * 只要有人再让 `kind` 抢在 mediaType 前面生效,这里就会红。 @@ -32,20 +32,20 @@ describe('派发决策的内容证据优先级不变量', () => { ]; const KINDS: readonly string[] = [ - 'ui', - 'UI', + 'unknown', + 'UNKNOWN', 'ui-design', - 'ui-prototype', + 'future-ui', 'image', 'scene', 'character', 'character-animation', - 'art-spritesheet', + 'icon-spritesheet', 'icon-spec', 'background-music', 'spec', - 'game-entry', - 'game-background', + 'future-game-entry', + 'scene', 'game-code', 'code', 'source', @@ -168,22 +168,22 @@ describe('派发决策的内容证据优先级不变量', () => { // 这里把该行为钉成显式合同,避免后人误当成本轮那条 `kind` 抢权的缺陷。 expect( projectResourceCardPreviewKind( - probeResource('ui', 'assets/probe.mp3', 'image/png'), + probeResource('ui-design', 'assets/probe.mp3', 'image/png'), ), ).toBe('audio'); expect( projectResourceCardPreviewKind( - probeResource('ui', 'assets/probe.mp4', 'image/png'), + probeResource('ui-design', 'assets/probe.mp4', 'image/png'), ), ).toBe('video'); }); test('本轮缺陷形状:JSON 规格无论 kind 写成什么都不得落占位或走图片分支', () => { for (const kind of [ - 'UI', - 'ui', + 'UNKNOWN', + 'unknown', 'ui-design', - 'ui-prototype', + 'future-ui', 'image', 'icon', ]) { @@ -204,10 +204,14 @@ describe('派发决策的内容证据优先级不变量', () => { test('mediaType 缺失时才由扩展名与 kind 决定分支', () => { expect( - projectResourceCardPreviewKind(probeResource('ui', 'assets/a.png', '')), + projectResourceCardPreviewKind( + probeResource('ui-design', 'assets/a.png', ''), + ), ).toBe('raster-image'); expect( - projectResourceCardPreviewKind(probeResource('UI', 'assets/a.json', '')), + projectResourceCardPreviewKind( + probeResource('unknown', 'assets/a.json', ''), + ), ).toBe('document'); expect( projectResourceCardPreviewKind( @@ -216,14 +220,14 @@ describe('派发决策的内容证据优先级不变量', () => { ).toBe('document'); expect( projectResourceCardPreviewKind( - probeResource('game-entry', 'game/index.html', ''), + probeResource('code', 'game/index.html', ''), ), ).toBe('code'); // `kind` 只能给出粗类型(art),无法确定具体渲染族;扩展名也判不出时落占位, // 这是"不进画布的未知二进制"而非本轮那条待读永不发起的缺陷。 expect( projectResourceCardPreviewKind( - probeResource('ui', 'assets/a.bundle', ''), + probeResource('ui-design', 'assets/a.bundle', ''), ), ).toBe('placeholder'); expect( diff --git a/apps/ai-game-creator-shell/tests/resourceTypePanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceTypePanel.test.tsx index d4b8a8bb4..434cbedde 100644 --- a/apps/ai-game-creator-shell/tests/resourceTypePanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceTypePanel.test.tsx @@ -34,7 +34,7 @@ const asset: GameCreationAppAssetManifestEntry = { * 落盘 `unclassified` 而 canonical `kind:"ui-design"` 能派生出 `ui-interaction`:这是读时 * 自愈的触发条件,也正是"选择器显示值 ≠ 落盘值"的现场。 * - * kind 只接受 canonical 值:写成 `"ui"` / `"UI"` 只会被收口成 `unknown`(派生仍是 + * kind 只接受 canonical 值;无法解析的输入收口成 `unknown`(派生仍是 * `unclassified`),自愈不触发,用例就失去要钉的行为。 */ const selfHealingAsset: GameCreationAppAssetManifestEntry = { @@ -460,7 +460,7 @@ describe('ResourceTypePanel 设置素材类型', () => { /** * 显式选「待归类」写下去的就是 `unclassified`,**不被 `kind` 派生值改写**: - * 该资产 `kind:"ui"` 能派生出 `ui-interaction`,若载荷改走读显示口径, + * 该 UI 设计资产能派生出 `ui-interaction`,若载荷改走读显示口径, * 用户点「待归类」就会落成 `ui-interaction`。 * * 变异验证:把载荷 `category` 换成 `gameCreationAppAssetCategory(asset)`,本用例必须失败。 diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 01876526e..6db7c7e3a 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -144,7 +144,7 @@ describe('useProjectResourceCardPreviews', () => { it('JSON 识别结果经过现有预取缓存保留,换项目后不复用旧编辑能力', async () => { const json = resource('design', { path: 'ui/design.json', - subtype: 'ui', + subtype: 'ui-design', mediaType: 'application/json', }); const invoke = vi.fn( 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 326a6c9b0..33870c0f0 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 | in-progress | +| Status | complete | | Owner | Codex | ## 修改边界 @@ -42,7 +42,7 @@ 3. 让 shell 的 shared-contracts 依赖打开绑定 feature;生成目录 2026-09-17 起收敛到 `packages/shared/src/contracts/generated/`(kind 是跨端契约,不再放 shell 私有目录)。 4. 增加 Rust serde round-trip 与 Unknown 解析/序列化测试;TS 侧不再手写生成值集合,改为从生成 union 穷举派生。 未知值通过 `parse_with_context` 收口到 `Unknown`,并把原始值与解析上下文交给壳层注册的 `app_log!` 回调(2026-09-17 起不再用 `tracing` / `kind-observability`)。 -5. 运行 shared-contracts 与 shell 定向测试,并用 `export_bindings` 复现生成文件;通过后暂停,等待下一个内部字段收紧切片。 +5. 运行 shared-contracts 与 shell 定向测试,并用 `export_bindings` 复现生成文件;完成后将稳定结论回写里程碑并关闭本计划。 ## 验证命令 diff --git a/docs/project-memory/plans/【实施计划】AGC资源kind词汇审计与绑定架构-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC资源kind词汇审计与绑定架构-2026-09-15.md index 88e10dd9a..2376edc3c 100644 --- a/docs/project-memory/plans/【实施计划】AGC资源kind词汇审计与绑定架构-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】AGC资源kind词汇审计与绑定架构-2026-09-15.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Milestone | `docs/project-memory/plans/【里程碑】AGC资源kind枚举化词汇审计与绑定架构-2026-09-15.md` | -| Status | ready | +| Status | complete | | Owner | Codex | ## 修改边界 @@ -17,7 +17,7 @@ 明确不修改: -- `apps/ai-game-creator-shell` 业务代码; +- 已完成的 `apps/ai-game-creator-shell` 业务收口不再属于未决范围;后续只允许按新里程碑变更; - server-side 代码、OpenAPI、SpacetimeDB schema; - MIME、`source.kind`、`generationKind` 和其他字符串领域。 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 512f9998d..221d37578 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 | in-progress | +| Status | complete | | Date | 2026-09-15 | | Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` | @@ -33,7 +33,7 @@ - [x] Rust enum 与生成的 TS 绑定值逐项一致,JSON 使用 kebab-case;正式资源成员为当前 17 个 canonical kind 加 `font`,另有 `Unknown` 解析成员。TS 侧不再手写值集合,改为对生成 union 做穷举 `Record`。 - [x] 未知输入得到 `Unknown`,日志包含原始值与调用上下文(`parse_with_context` + 壳层在 `main()` 注册的 `app_log!` 回调,见 2026-09-17 收敛)。 -- [x] 生产 writer 不再接受裸 `string` 作为 GameCreationApp kind(manifest 写入、资源登记、direct runtime、平台美术登记、资源编辑派生均只传枚举成员)。 +- [x] 生产 writer 不再接受裸 `string` 作为 GameCreationApp kind(manifest 写入、资源登记、direct runtime、平台美术登记、资源编辑派生均只传枚举成员);`Unknown` 仅表示解析边界无法判定,调用方可拒绝或交后续归类。 - [x] `font`、`audio`、`sound-effect`、`background-music` 均有真实生产路径(字体导入、上传音频推断、资源编辑音频派生)。 - [x] ts-rs 绑定由 `cargo test export_bindings` 生成,生成目录加入 `.prettierignore` / eslint `ignorePatterns`,可幂等复现且不污染工作树。 - [x] 退役的 `BindingDTO` / `BindingChange` 不再导出,手写的 `exports_ui_editor_types` 已删除。 diff --git a/docs/project-memory/plans/【里程碑】AGC资源kind枚举化写入方清理与验证-2026-09-15.md b/docs/project-memory/plans/【里程碑】AGC资源kind枚举化写入方清理与验证-2026-09-15.md index cb8083312..d1b1626e7 100644 --- a/docs/project-memory/plans/【里程碑】AGC资源kind枚举化写入方清理与验证-2026-09-15.md +++ b/docs/project-memory/plans/【里程碑】AGC资源kind枚举化写入方清理与验证-2026-09-15.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | planned | +| Status | complete | | Date | 2026-09-15 | | Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` | @@ -16,7 +16,7 @@ - manifest 登记、恢复、回填、资源替换、画板/图集导出、字体导入、上传推断和 Agent 资源登记。 - shell 前端资源投影、筛选和资源引用消费方。 - 删除 alias/fallback 生产逻辑及其只服务于旧值的测试。 -- 为每个仍保留的非 manifest kind 字符串补充 todo,转入后续独立 PR。 +- 对仍保留的 workflow、generation、路径和其他领域字符串完成边界核对,不将其重新解释为 manifest kind。 ## 不在范围内 @@ -31,7 +31,7 @@ ## 验收标准 -- [ ] 新建、导入、生成、恢复、替换路径不会写入未纳入 enum 的 manifest kind。 -- [ ] 未知外部值只进入 `Unknown` 并有 trace,不会被静默改成 `image` 或 `asset`。 -- [ ] `source.kind` 与 `generationKind` 的既有字符串行为不变。 -- [ ] 定向 Rust、TS、绑定生成、编码和 diff 检查通过。 +- [x] 新建、导入、生成、恢复、替换路径不会写入未纳入 enum 的 manifest kind。 +- [x] 未知外部值只进入 `Unknown` 并有 trace;缺失输入的中性默认仅由明确登记入口决定。 +- [x] `source.kind` 与 `generationKind` 的既有字符串行为不变。 +- [x] 定向 Rust、TS、绑定生成、编码和 diff 检查通过。 diff --git a/docs/project-memory/plans/【里程碑】AGC资源kind枚举化词汇审计与绑定架构-2026-09-15.md b/docs/project-memory/plans/【里程碑】AGC资源kind枚举化词汇审计与绑定架构-2026-09-15.md index dd6b7e080..782798962 100644 --- a/docs/project-memory/plans/【里程碑】AGC资源kind枚举化词汇审计与绑定架构-2026-09-15.md +++ b/docs/project-memory/plans/【里程碑】AGC资源kind枚举化词汇审计与绑定架构-2026-09-15.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | approved | +| Status | complete | | Date | 2026-09-15 | | Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` | @@ -22,8 +22,8 @@ - 不扫描或修改 api-server 其他领域的 `asset_kind`;图片快速编辑来源校验属于本次 GameCreationApp manifest 边界,必须复用共享枚举且不得保留 legacy 白名单。 - 不改 `mediaType`、MIME、文件扩展名、`source.kind`、`source.generationKind`、任务/工作流 kind、路径名或 tracking scope。 -- 不在本里程碑修改 manifest writer;前端消费方仅清理退役 kind 的恒假判断,不扩展公开契约。 -- 不实现旧 manifest migration;旧值只在共享解析边界收口为 `Unknown`,在图片快速编辑请求边界被拒绝,不增加 alias 表或 fallback。 +- manifest writer 与前端消费方已统一到共享 Rust enum/ts-rs binding;不扩展公开契约。 +- 不实现旧 manifest migration;无法解析的输入只在共享解析边界收口为 `Unknown`,由调用方拒绝或交后续归类,不增加 alias 表或 fallback。 ## 依赖与前置条件 @@ -33,10 +33,10 @@ ## 验收标准 -- [ ] shell 内 manifest 资源 kind 的生产写入点、解析点、投影点和测试 fixture 有完整清单。 +- [x] shell 内 manifest 资源 kind 的生产写入点、解析点、投影点和测试 fixture 已完成审计并收口。 - [x] 每个旧值均已标注为:正式 GameCreationApp kind、其他领域字符串、legacy/错误 fallback;没有只按 grep 结果猜测的条目。 -- [ ] api-server 有效值、shell 扩展值和排除值各有明确依据。 -- [ ] 生成架构不会要求 server-side 默认构建安装 ts-rs。 +- [x] api-server 有效值、shell 扩展值和排除值各有明确依据。 +- [x] 生成架构不会要求 server-side 默认构建安装 ts-rs。 - [x] todo 清单的每项都有关闭条件和对应验证证据;图片快速编辑不再维护兼容白名单,退役 kind 直接失败。 ## 证据要求 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 66184b7bc..63166fdd8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9,10 +9,10 @@ - 决策(单一解析入口):`GameCreationAppAssetKind::parse_with_context()` 是唯一公开解析入口,严格匹配降为私有 `match_canonical()`;易混的 `from_str_lossy()` 与公开 `from_str_or_unknown()` 都已删除,认不出 canonical 值只有这一处收口并留痕(读侧回捞 legacy kind 只查 `ALL` 判真值,不另开解析函数)。口径是等值匹配——不 trim、不 lowercase、不查别名、不迁移;认不出的值收口成 `Unknown`(分类落 `unclassified`)。这是有意接受的行为(历史误写的 kind 不会被"救回"正确栏目),不再提供任何兼容入口;`font` 是正式成员,不再走别名。 - 决策(登记边界的 kind 口径):外部登记统一走 `assets::registration_asset_kind()`——空白入参落中性 `image`,非空认不出的值严格收口成 `unknown` 并留痕;三条登记边界(Tauri `register_local_asset`、画板导入、平台导入)不再各写一份 trim/兜底。`Unknown` 是无法解析的边界结果,具体写入方必须自行决定拒绝或保留待后续归类,不能静默猜成其他 kind。栅格归一化的 `source_subtype` 只接受图片族成员,文档/字体/音频等一律按 `image` 登记;Agent 回执派生物按 `Text => Document` 落 `document`。 - 决策(判据不许散落):切片残留登记只看 `assets/art-spritesheet-slices/` 路径(不再附带 `kind == Icon`);sprite 身份比较忽略随 kind 派生的 `metadata.asset_type`;TS 侧「UI 编辑器文档资产」判据只留 `isGameCreationAppUiDesignDocAsset()` 一份,资源画布入口 / UI 编辑器桥接 / 资源引用缩略图统一调用。 -- 决策(已知代价,不补救):既有项目里 legacy kind 读入即 `unknown`,依赖 kind 等值比较的运行门禁会按"缺少该资源"处理。这是「不迁移、不 fallback」的必然结果,本次明确不为存量数据做迁移;将来若要迁就必须单独立项(一次性迁移或读侧白名单),不得把别名表加回解析路径。 +- 决策(已知代价,不补救):既有项目里无法解析的 kind 读入即 `unknown`,依赖 kind 等值比较的运行门禁会按"缺少该资源"处理。这是严格解析的必然结果,本次明确不为存量数据做迁移;将来若要迁就必须单独立项,不能改写解析边界。 - 决策(留痕必须真的落地):原实现用 `tracing::warn!`,而 AGC 壳没有 tracing subscriber,等于没有日志。现在 `shared-contracts` 只暴露可注册回调 `set_non_canonical_asset_kind_reporter()`,AGC 壳在 `main()` 里接到 `app_log!`,日志同时含原始输入串与调用上下文;`kind-observability` feature 与 `tracing` 依赖一并删除。TS 侧对应 `parseGameCreationAppAssetKind()` 的 `console.warn`。 -- 决策(平台/画板词汇表):`AGENT_RUNTIME_CANVAS_ASSET_KINDS`(`canvas.asset_generate.assetKind` 的 enum)与 `platform_art_asset_manifest_kind()` 是「平台词汇 → manifest kind」的唯一映射点,保留原词汇但把认不出的原值改为留痕收口;图片快速编辑来源直接解析 `GameCreationAppAssetKind` 的 canonical 静态图片成员并要求 `mediaType=image`,原 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS` 兼容白名单已删除,退役 kind 不再作为 GameCreationApp manifest 来源放行。 -- 验证:`cargo test -p shared-contracts`(含词汇表唯一性、严格性与留痕用例)、`cargo test --locked -p shared-contracts --features ts-bindings export_bindings` 后 `git diff` 为空、AGC bin 定向用例(`platform_art_asset_manifest_kind_maps_platform_vocabulary_explicitly`、`derived_asset_manifest_kind_is_never_unknown_for_text_derivatives`、`non_canonical_manifest_asset_kinds_report_raw_values_only`)、`npx vitest run packages/shared/src/contracts/gameCreationApp.test.ts apps/ai-game-creator-shell/tests/uiDesignResourceBridge.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts`。 +- 决策(平台/画板词汇表):平台生成输入先严格解析为 `GameCreationAppAssetKind`,登记时直接写入已解析的 enum,不再保留 `platform_art_asset_manifest_kind()` 或任何平台别名/fallback 映射;图片快速编辑来源同样只允许 canonical 静态图片成员并要求 `mediaType=image`,原 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS` 兼容白名单已删除。 +- 验证:`cargo test -p shared-contracts`(含词汇表唯一性、严格性与留痕用例)、`cargo test --locked -p shared-contracts --features ts-bindings export_bindings` 后 `git diff` 为空、AGC bin 定向用例(`derived_asset_manifest_kind_is_never_unknown_for_text_derivatives`、`non_canonical_manifest_asset_kinds_report_raw_values_only`)、`npx vitest run packages/shared/src/contracts/gameCreationApp.test.ts apps/ai-game-creator-shell/tests/uiDesignResourceBridge.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts`。 - 关联文档:[AI 游戏创作智能体 App 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的 2026-09-17 节。 ## 2026-09-16 DirectProject 引用渲染收敛到 canonical user item 深模块 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 98fdc7009..f4ba6c847 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -16,11 +16,13 @@ import { GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION, + GAME_CREATION_APP_UI_DESIGN_ASSET_KIND, GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND, GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, type GameCreationAgentRunTrace, gameCreationAppAssetCategory, gameCreationAppAssetCategoryForKind, + gameCreationAppAssetCategoryForRawKind, type GameCreationAppAssetManifestEntry, gameCreationAppAssetPersistedCategory, gameCreationAppAssetTags, @@ -751,32 +753,27 @@ describe('AI 游戏创作 App 共享契约', () => { }); }); - it('parses kind strictly and never aliases non-canonical values', () => { + it('parses kind strictly and closes unknown values over unknown', () => { expect(GAME_CREATION_APP_ASSET_KINDS).toContain('character-animation'); expect(GAME_CREATION_APP_ASSET_KINDS).toContain('font'); expect(GAME_CREATION_APP_ASSET_KINDS).toContain('unknown'); - // 口径是严格接受:不 trim、不做大小写归一、不查别名表、不做迁移。 - for (const raw of [ - 'game-background', - 'character-art', - 'ui-prototype', - 'art-spritesheet', - 'art-spritesheet-slice', - 'illustration', - 'UI', - 'UI-Prototype', - 'FONT', - ' image ', - ]) { - expect(parseGameCreationAppAssetKind(raw, 'test.legacy')).toBe('unknown'); - expect(gameCreationAppAssetCategoryForKind(raw)).toBe('unclassified'); + // 口径是严格接受:只认精确的 canonical 值。 + for (const raw of ['future-kind', 'UI', 'FONT', ' image ']) { + expect(parseGameCreationAppAssetKind(raw, 'test.kind-boundary')).toBe( + 'unknown', + ); + expect( + gameCreationAppAssetCategoryForRawKind(raw, 'test.kind-category'), + ).toBe('unclassified'); } - expect(parseGameCreationAppAssetKind('font', 'test.legacy')).toBe('font'); - expect(parseGameCreationAppAssetKind('character', 'test.legacy')).toBe( - 'character', + expect(parseGameCreationAppAssetKind('font', 'test.kind-boundary')).toBe( + 'font', ); - expect(parseGameCreationAppAssetKind('unknown', 'test.legacy')).toBe( + expect( + parseGameCreationAppAssetKind('character', 'test.kind-boundary'), + ).toBe('character'); + expect(parseGameCreationAppAssetKind('unknown', 'test.kind-boundary')).toBe( 'unknown', ); @@ -794,9 +791,9 @@ describe('AI 游戏创作 App 共享契约', () => { expect(parseGameCreationAppAssetKind(prototypeKey, 'test.legacy')).toBe( 'unknown', ); - expect(gameCreationAppAssetCategoryForKind(prototypeKey)).toBe( - 'unclassified', - ); + expect( + gameCreationAppAssetCategoryForRawKind(prototypeKey, 'test.legacy'), + ).toBe('unclassified'); } }); @@ -840,8 +837,13 @@ describe('AI 游戏创作 App 共享契约', () => { }), ).toBe(false); - // legacy / 非 canonical kind 一律严格收口,不被当作文档(判据只做等值匹配)。 - for (const raw of ['UI', 'ui', 'ui-design-doc ', 'UI-DESIGN-DOC']) { + // 非 canonical kind 一律严格收口,不被当作文档(判据只做等值匹配)。 + for (const raw of [ + 'UI', + 'future-kind', + 'ui-design-doc ', + 'UI-DESIGN-DOC', + ]) { expect( isGameCreationAppUiDesignDocAsset({ kind: raw, @@ -872,14 +874,19 @@ describe('AI 游戏创作 App 共享契约', () => { expect(gameCreationAppAssetCategoryForKind('scene')).toBe('scene'); expect(gameCreationAppAssetCategoryForKind('sound-effect')).toBe('audio'); expect(gameCreationAppAssetCategoryForKind('spec')).toBe('document'); - for (const kind of ['image', 'video', 'code', 'publication-material']) { + for (const kind of [ + 'image', + 'video', + 'code', + 'publication-material', + ] as const) { expect(gameCreationAppAssetCategoryForKind(kind)).toBe('unclassified'); } // `font` 是 canonical 成员(现役写入侧直接写 `font`),分类是 `document`。 expect(gameCreationAppAssetCategoryForKind('font')).toBe('document'); - expect(gameCreationAppAssetCategoryForKind('unknown-kind')).toBe( - 'unclassified', - ); + expect( + gameCreationAppAssetCategoryForRawKind('unknown-kind', 'test.unknown'), + ).toBe('unclassified'); expect(gameCreationAppAssetCategoryForKind('unknown')).toBe('unclassified'); }); @@ -896,7 +903,7 @@ describe('AI 游戏创作 App 共享契约', () => { // 有意行为(2026-09-10 分类自愈批次):落盘 `unclassified` 而 kind 能派生出明确的 // 非 `unclassified` 分类时,读取侧采用派生值。这条规则用于自愈历史上被系统误写成 - // `unclassified` 的存量(真机 57 条 `kind:"ui"` 资产),无需迁移脚本且永久生效。 + // `unclassified` 的存量由 canonical kind 的读显示口径决定,无需迁移脚本且永久生效。 // 已知盲区:用户手动把可明确分类的资产设为「待归类」时该手动值会被覆盖,属有意接受。 expect( gameCreationAppAssetCategory({ ...legacy, category: 'unclassified' }), @@ -950,8 +957,7 @@ describe('AI 游戏创作 App 共享契约', () => { * `unclassified` 的存量(真机出现过 UI 资产落盘 `unclassified` 而 kind 已是 * `ui-design` 的情况),不写迁移脚本且永久生效。 * - * 这条规则**不含**任何 kind 别名归一:认不出的 kind 收口成 `unknown`,派生结果同样是 - * `unclassified`,规则不触发。 + * 认不出的 kind 收口成 `unknown`,派生结果同样是 `unclassified`,规则不触发。 */ it('自愈规则覆盖落盘 unclassified:这是显式接受的盲区', () => { // 落盘 unclassified + kind 可明确分类 → 派生值覆盖落盘值。 @@ -976,9 +982,12 @@ describe('AI 游戏创作 App 共享契约', () => { category: 'unclassified', }), ).toBe('ui-interaction'); - // legacy 原值不在自愈窗口内:它连 canonical kind 都不是。 + // unknown kind 不在自愈窗口内:它没有可派生的明确分类。 expect( - gameCreationAppAssetCategory({ kind: 'ui', category: 'unclassified' }), + gameCreationAppAssetCategory({ + kind: 'future-kind', + category: 'unclassified', + }), ).toBe('unclassified'); }); @@ -1007,7 +1016,10 @@ describe('AI 游戏创作 App 共享契约', () => { gameCreationAppAssetCategory({ kind: 'character', category: 'audio' }), ).toBe('audio'); expect( - gameCreationAppAssetCategory({ kind: 'ui', category: 'document' }), + gameCreationAppAssetCategory({ + kind: GAME_CREATION_APP_UI_DESIGN_ASSET_KIND, + category: 'document', + }), ).toBe('document'); }); @@ -1041,7 +1053,10 @@ describe('AI 游戏创作 App 共享契约', () => { // 落盘值合法且非 unclassified 时两个口径一致。 expect( - gameCreationAppAssetPersistedCategory({ kind: 'ui', category: 'audio' }), + gameCreationAppAssetPersistedCategory({ + kind: GAME_CREATION_APP_UI_DESIGN_ASSET_KIND, + category: 'audio', + }), ).toBe('audio'); }); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 50b0cb434..0b3271271 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -518,13 +518,7 @@ export function isGameCreationAppUiDesignDocAsset( /** * 运行期 kind 列表:穷举 Record 保证它不可能与生成 union 分叉(漏一个/多一个都编译报错)。 - * 键顺序即生成声明顺序。kind 字符串表**只有这一份运行期副本**,别名表已删除。 - * - * kind 的**唯一真源**是 Rust 侧 `GameCreationAppAssetKind`(ts-rs 生成 - * `./generated/GameCreationAppAssetKind`)。旧的手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS`、 - * legacy 别名表与 `canonicalGameCreationAppAssetKind()` 已从本文件删除,Rust 侧同名的 - * `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` / `canonical_game_creation_app_asset_kind()` - * 一并删除。口径是严格接受:不 trim、不大小写归一、不查别名、不做迁移。 + * 键顺序即生成声明顺序。kind 字符串表只有这一份运行期副本;输入解析只接受 canonical 值。 */ const GAME_CREATION_APP_ASSET_KIND_MEMBERS: Record< GameCreationAppAssetKind, @@ -618,7 +612,7 @@ export function isGameCreationAppAssetKind( * 严格解析外部 kind 字符串(manifest / API):只认 canonical 值,认不出的收口成 `unknown` * 并把**原始串**打到 `console.warn`(Rust 侧同类留痕走 `app_log!`)。 * - * 不 trim、不做大小写归一、不查别名表、不做迁移:`"UI"` / `"ui"` / `" image "` 一律 `unknown`。 + * 只接受精确的 canonical 值,其他输入一律收口为 `unknown`。 */ export function parseGameCreationAppAssetKind( raw: unknown, @@ -699,11 +693,19 @@ export function normalizeGameCreationAppAssetCategory( } export function gameCreationAppAssetCategoryForKind( - kind: string, + kind: GameCreationAppAssetKind, ): GameCreationAppAssetCategory { - return GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[ - parseGameCreationAppAssetKind(kind, 'asset-category.by-kind') - ]; + return GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind]; +} + +/** 解析外部 kind 后再按共享枚举派生分类;内部业务逻辑应传入已解析的枚举。 */ +export function gameCreationAppAssetCategoryForRawKind( + raw: unknown, + context: string, +): GameCreationAppAssetCategory { + return gameCreationAppAssetCategoryForKind( + parseGameCreationAppAssetKind(raw, context), + ); } /** @@ -718,8 +720,7 @@ export function gameCreationAppAssetCategoryForKind( * **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的 * 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成 * unclassified」的存量数据——典型例子是 UI 资产落盘 `unclassified` 而 kind 已是 `ui-design`。 - * kind 认不出时只收口成 `unknown`(派生同样是 `unclassified`)并留痕原始串, - * **不查别名表**:这条自愈规则不负责把 legacy kind 映射回 canonical。 + * kind 认不出时只收口成 `unknown`(派生同样是 `unclassified`)并留痕原始串。 * * 盲区:用户**手动**把一个 kind 已能明确分类的资产设成「待归类」时,该手动值会被这条 * 规则覆盖。这是有意接受的取舍——「手动设为待归类」本身意图边缘,且 kind 已经表达了分类; @@ -738,7 +739,9 @@ export function gameCreationAppAssetCategory( asset: Pick, ): GameCreationAppAssetCategory { const persisted = normalizeGameCreationAppAssetCategory(asset.category); - const derivedFromKind = gameCreationAppAssetCategoryForKind(asset.kind); + const derivedFromKind = gameCreationAppAssetCategoryForKind( + parseGameCreationAppAssetKind(asset.kind, 'asset-category.display'), + ); if (persisted === null) { return derivedFromKind; } @@ -765,7 +768,9 @@ export function gameCreationAppAssetPersistedCategory( ): GameCreationAppAssetCategory { return ( normalizeGameCreationAppAssetCategory(asset.category) ?? - gameCreationAppAssetCategoryForKind(asset.kind) + gameCreationAppAssetCategoryForKind( + parseGameCreationAppAssetKind(asset.kind, 'asset-category.persisted'), + ) ); } 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 f0ffea11a..4b70f31b2 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -550,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(|| { ::custom(format!( "未知的素材分类 category = {raw}:本客户端会整结构体重写 manifest,\ @@ -671,7 +673,7 @@ pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: &[( GameCreationAppAssetKind::Code, GameCreationAppAssetCategory::Unclassified, ), - // 认不出的 kind 一律落「待归类」:不做别名归一,也不猜。 + // 认不出的 kind 一律落「待归类」。 ( GameCreationAppAssetKind::Unknown, GameCreationAppAssetCategory::Unclassified, @@ -692,15 +694,26 @@ pub fn game_creation_app_asset_category_from_str( } } -pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppAssetCategory { - let canonical = GameCreationAppAssetKind::parse_with_context(kind, "asset-category.by-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` 是同一套口径的两份实现,两侧不许各写一份: @@ -713,14 +726,14 @@ pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppA /// 不写迁移脚本且永久生效;`kind` 派生结果本身就是 `unclassified` 的 /// (`image / video / code / publication-material`,以及认不出的 kind)仍信任落盘值。 /// -/// kind 认不出时解析边界只收口成 `Unknown` 并留痕(见 `GameCreationAppAssetKind:: -/// parse_with_context`),**不查别名表**:这条自愈规则不负责把 legacy kind 映射回 canonical。 +/// 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 { @@ -2181,8 +2194,8 @@ mod tests { } #[test] - fn manifest_unknown_kind_is_preserved_as_unknown_without_alias_migration() { - let entry = asset_entry_from_json(asset_entry_json("ui")); + 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"); @@ -2202,58 +2215,58 @@ mod tests { .unwrap_or_else(|| panic!("kind 缺少功能分类映射:{}", kind.as_str())); assert_eq!( mapped.1, - game_creation_app_asset_category_for_kind(kind.as_str()), + 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("font"), + game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Font), GameCreationAppAssetCategory::Document ); // 严格口径:非 canonical 原值(含大小写变体与 `Object.prototype` 上的键)一律落 - // 「待归类」。没有别名表,也不许有 `image` 这类兜底归一。 + // 「待归类」。 for prototype_key in ["constructor", "__proto__", "toString", "valueOf"] { 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 ); } - for raw in ["UI", "ui", "game-background", "unknown-kind"] { + for raw in ["UI", "future-kind", "unknown-kind"] { assert_eq!( - game_creation_app_asset_category_for_kind(raw), + game_creation_app_asset_category_for_raw_kind(raw, "test.category"), GameCreationAppAssetCategory::Unclassified, "{raw} 是非 canonical 值,不得被归一成别的分类" ); @@ -2269,7 +2282,7 @@ mod tests { const EFFECTIVE_CATEGORY_CONTRACT: [(&str, &str, &str); 8] = [ // 落盘值合法且明确 → 落盘值为准。 ("character", "character", "character"), - ("ui", "scene", "scene"), + ("ui-design", "scene", "scene"), ("image", "audio", "audio"), // 落盘 unclassified 且 kind 能派生明确分类 → 自愈成派生值。 ("ui-design", "unclassified", "ui-interaction"), @@ -2288,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:?}" ); @@ -2304,9 +2320,9 @@ mod tests { let icon = asset_entry_from_json(asset_entry_json("icon-spritesheet")); assert_eq!(icon.category, GameCreationAppAssetCategory::UiInteraction); - // 缺 `category` 时按 kind 派生;非 canonical 的 kind 没有别名表, - // 一律落「待归类」,判定结果只出现在留痕日志里。 - for raw_kind in ["game-background", "ui", "unknown-kind"] { + // 缺 `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!( diff --git a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs index b341d042c..386d08c5b 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs @@ -35,11 +35,10 @@ fn report_non_canonical_asset_kind(raw_kind: &str, context: &str) { /// GameCreationApp manifest 资源 kind 的**唯一**词汇表。 /// /// 枚举变体、线上值(kebab-case)、`as_str()`、`ALL`、严格解析与 ts-rs 绑定全部由下面这张 -/// 声明表派生:仓库里不再有第二份 kind 列表(旧的手写 canonical 列表、legacy 别名表、 -/// `font` 特例都已删除),TS 侧也只剩这一份生成 union。 +/// 声明表派生,TS 侧消费生成的 union。 /// -/// 口径:**只接受 canonical 值**。认不出的输入收口成 `Unknown`,不做 trim、不做大小写归一、 -/// 不查别名表、不做迁移;留痕由 [`Self::parse_with_context`] 交给已注册的 reporter。 +/// 口径:**只接受 canonical 值**。认不出的输入收口成 `Unknown`;留痕由 +/// [`Self::parse_with_context`] 交给已注册的 reporter。 macro_rules! game_creation_app_asset_kinds { ($($variant:ident => $wire:literal),+ $(,)?) => { /// GameCreationApp manifest 资源 kind 的**唯一**类型,也是 TS 侧 kind union 的唯一真源 @@ -146,8 +145,8 @@ macro_rules! game_creation_app_asset_kinds { /// **唯一**的 kind 解析入口:严格等值匹配,认不出 canonical 值时把原始串与上下文 /// 交给已注册的 reporter(AGC 壳注册成 `app_log!`)。 /// - /// 不做 trim、不做大小写归一、不查别名表、不做迁移——认不出就收口成 `Unknown`, - /// 而不是 `Err`:反序列化必须不可失败(一份 legacy manifest 不该让整个项目读不出来)。 + /// 认不出就收口成 `Unknown`,而不是 `Err`:反序列化必须不可失败, + /// 以便调用方决定后续处理。 /// 字面 `"unknown"` 本身是合法输入,不算异常,不报。 pub fn parse_with_context(value: &str, context: &'static str) -> Self { let parsed = Self::match_canonical(value);