From d526439508a5823af2058246d6e4b1d771320ee2 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 10:13:46 +0800 Subject: [PATCH] =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=B5=84=E6=BA=90=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=8D=87=E7=BA=A7=E4=B8=BA=E7=9C=9F=E5=AE=9E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=A7=BD=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 game_iteration_resource_bindings:优先使用使用清单里的 slotId 到 resourceId 绑定,再为未被槽位覆盖的资源补 asset:{id} 恒等绑定 - 恒等绑定保留是有意的:历史版本与既有校验语义不受影响,同一资源被多个槽位使用时只取首个槽位,不产生冲突语义 - ensure_initial_game_iteration_version_at 与 append_agent_game_iteration_version_at 共用该构造函数 - 补测试:无使用清单时保持恒等绑定;登记运行槽位后槽位绑定优先且未覆盖资源仍有兜底 --- .../src-tauri/src/project/manifest.rs | 63 +++++++--- .../src-tauri/src/tests/asset_usage.rs | 110 ++++++++++++++++++ 2 files changed, 157 insertions(+), 16 deletions(-) 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 c9e73140c..c4c3c1608 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 @@ -718,6 +718,51 @@ pub(crate) fn read_existing_manifest_for_project( Ok(manifest) } +/// 构造正式版本的资源绑定。 +/// +/// 绑定分两部分,顺序固定: +/// +/// 1. **运行槽位绑定**:来自运行期资源使用清单的真实 `slotId → resourceId`。这是替换与 +/// 点选需要的语义——同一个资源在不同运行位置可以被单独替换。 +/// 2. **恒等绑定**:`asset:{id} → id`,与历史版本保持一致的兜底,保证未被运行槽位覆盖的 +/// 资源仍然有绑定,历史版本与旧校验语义不受影响。 +/// +/// 槽位绑定优先:同一资源若已被某个槽位绑定,不再为它补恒等绑定,避免同一资源在版本里 +/// 出现两份互相冲突的语义。 +pub(crate) fn game_iteration_resource_bindings( + root: &Path, + manifest: &GameCreationAppManifest, +) -> Vec { + let slot_bindings = read_local_project_asset_usage(root, Some(&manifest.project_id)) + .ok() + .flatten() + .map(|usage| usage.slot_bindings()) + .unwrap_or_default(); + + let mut bound_resource_ids: Vec = Vec::new(); + let mut bindings: Vec = Vec::new(); + for (slot_id, resource_id) in slot_bindings { + if bound_resource_ids.iter().any(|id| id == &resource_id) { + continue; + } + bound_resource_ids.push(resource_id.clone()); + bindings.push(GameIterationVersionResourceBinding { + slot_id, + resource_id, + }); + } + for asset in &manifest.assets { + if bound_resource_ids.iter().any(|id| id == &asset.id) { + continue; + } + bindings.push(GameIterationVersionResourceBinding { + slot_id: format!("asset:{}", asset.id), + resource_id: asset.id.clone(), + }); + } + bindings +} + /// Registers the first formally playable project version after the current /// revision has produced a durable successful browser-playtest receipt. /// Replays are idempotent: once any formal version exists, validation never @@ -733,14 +778,7 @@ pub(crate) fn ensure_initial_game_iteration_version_at( if !manifest.versions.is_empty() { return Ok(false); } - let resource_bindings = manifest - .assets - .iter() - .map(|asset| GameIterationVersionResourceBinding { - slot_id: format!("asset:{}", asset.id), - resource_id: asset.id.clone(), - }) - .collect(); + let resource_bindings = game_iteration_resource_bindings(root, &manifest); manifest.versions.push(GameIterationVersion { version_id: format!("initial-{project_revision}"), parent_version_id: None, @@ -779,14 +817,7 @@ pub(crate) fn append_agent_game_iteration_version_at( return Err("Agent 项目版本必须绑定大于 0 的项目 revision".to_string()); } mutate_manifest_at(root, |manifest| { - let resource_bindings = manifest - .assets - .iter() - .map(|asset| GameIterationVersionResourceBinding { - slot_id: format!("asset:{}", asset.id), - resource_id: asset.id.clone(), - }) - .collect::>(); + let resource_bindings = game_iteration_resource_bindings(root, manifest); if let Some(existing) = manifest .versions .iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs index dbd3c3885..5cf35c52b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs @@ -362,6 +362,116 @@ fn asset_usage_read_returns_none_when_absent() { fs::remove_dir_all(root).ok(); } +/// 版本绑定升级:有运行槽位时用真实 `slotId → resourceId`, +/// 未覆盖的资源补恒等绑定,保证历史语义与旧校验不受影响。 +#[test] +fn game_iteration_bindings_prefer_runtime_slots_and_keep_identity_fallback() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "版本绑定升级测试").expect("init project"); + write_asset_usage_asset(&root, "assets/hero.png", b"hero"); + write_asset_usage_asset(&root, "memory/design.md", b"# design"); + register_local_asset_at( + &root, + "assets/hero.png", + "character", + "image/png", + "generated", + generated_asset_source("art-asset-plan"), + ) + .expect("register hero asset"); + register_local_asset_at( + &root, + "memory/design.md", + "document", + "text/markdown", + "generated", + generated_asset_source("design-foundation"), + ) + .expect("register design asset"); + let manifest = read_manifest_for_project(&root).expect("read manifest with registered assets"); + assert_eq!(manifest.assets.len(), 2, "fixture registers two assets"); + + // 无使用清单:绑定保持恒等,历史语义不变。 + let identity_bindings = game_iteration_resource_bindings(&root, &manifest); + let identity_pairs: Vec<(String, String)> = identity_bindings + .iter() + .map(|binding| (binding.slot_id.clone(), binding.resource_id.clone())) + .collect(); + assert_eq!(identity_pairs.len(), 2); + for pair in &identity_pairs { + assert_eq!( + pair.0, + format!("asset:{}", pair.1), + "缺少使用清单时必须保持恒等绑定:{pair:?}" + ); + } + + // 登记运行槽位后:槽位绑定优先,未覆盖资源仍补恒等绑定。 + let assets: Vec = manifest + .assets + .iter() + .map(|asset| LocalProjectAssetFacts { + asset_id: asset.id.clone(), + local_path: asset.local_path.clone(), + }) + .collect(); + let hero_asset_id = manifest + .assets + .iter() + .find(|asset| asset.local_path == "assets/hero.png") + .expect("hero asset registered") + .id + .clone(); + + submit_local_project_asset_usage_at( + &root, + &manifest.project_id, + &assets, + &asset_usage_submit( + 1, + vec![ + asset_usage_reference(Some("hero"), Some(&hero_asset_id), None), + asset_usage_reference(Some("title-hero"), Some(&hero_asset_id), None), + ], + ), + ) + .expect("register runtime slots"); + + let slot_bindings = game_iteration_resource_bindings(&root, &manifest); + let slot_pairs: Vec<(String, String)> = slot_bindings + .iter() + .map(|binding| (binding.slot_id.clone(), binding.resource_id.clone())) + .collect(); + assert_eq!( + slot_pairs.len(), + 2, + "同一资源的第二个槽位不重复绑定,且未覆盖资源保留恒等绑定:{slot_pairs:?}" + ); + assert_eq!(slot_pairs[0].0, "hero"); + assert_eq!(slot_pairs[0].1, hero_asset_id); + assert!( + slot_pairs[1].0.starts_with("asset:"), + "未被运行槽位覆盖的资源必须保留恒等绑定兜底:{slot_pairs:?}" + ); + + fs::remove_dir_all(root).ok(); +} + +fn generated_asset_source(task_id: &str) -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some(task_id.to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + } +} + /// 落盘正文只包含引用与诊断,不含 manifest 已有的资源元数据。 #[test] fn asset_usage_persisted_payload_omits_manifest_owned_metadata() {