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 a140eb6d9..7c6416dc4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -999,6 +999,42 @@ pub(crate) fn delete_local_project_asset( ) } +/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。 +/// +/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。 +#[tauri::command] +pub(crate) fn read_local_project_version_resource_replacement_candidates( + input: ReadLocalProjectVersionReplacementCandidatesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + read_local_project_version_replacement_candidates_at( + root, + &input.source_version_id, + &input.source_resource_id, + ) +} + +/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。 +/// +/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本; +/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。 +#[tauri::command] +pub(crate) fn replace_local_project_version_resource( + input: ReplaceLocalProjectVersionResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + replace_local_project_version_resource_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.source_version_id, + &input.source_resource_id, + &input.replacement_resource_id, + ) +} + /// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 /// /// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 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 c36311362..9ead2c5c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2697,6 +2697,8 @@ fn main() { delete_local_project_asset, read_local_project_asset_references, rename_local_project_asset, + read_local_project_version_resource_replacement_candidates, + replace_local_project_version_resource, get_local_game_project_revision, get_local_game_manifest, download_agc_update, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index b91cb3959..3cd6e4ac2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -17,6 +17,7 @@ mod resource_dependency_graph; mod resource_editor; mod resource_layout; mod verification; +mod version_resource_replacement; mod write_lock; pub(crate) use agent_db::*; @@ -33,4 +34,5 @@ pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; +pub(crate) use version_resource_replacement::*; pub(crate) use write_lock::*; 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 new file mode 100644 index 000000000..b68f5175b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs @@ -0,0 +1,482 @@ +use super::*; + +use shared_contracts::game_creation_app::{ + canonical_game_creation_app_asset_kind, + game_creation_app_asset_category_with_read_time_healing, + GameCreationAppAssetManifestEntry, +}; + +/// 版本级资源替换(PRD §3.2 / §5.3)。 +/// +/// 落盘语义:**改 manifest 绑定,但落在追加的新版本上** —— 不原地修改既有版本、不动资源文件、 +/// 不建文件副本。一次 CAS 写入里完成三件事: +/// 1. 追加一条 `createdReason = resource-replacement` 的新版本,`parentVersionId` 指向被替换的源版本; +/// 2. 新版本的 `resourceBindings` = 源版本绑定集合**去掉源素材**,并保证**替换素材在集合里** +/// (替换素材是源版本创建之后才登记时,按源素材原来的位置插回,顺序稳定); +/// 3. 替换前后的资源身份由两份不可变记录 + `parentVersionId` + `createdReason` 确定: +/// 源素材的 `asset:{sourceResourceId}` 绑定在父版本里存在、在子版本里消失;替换素材的绑定在 +/// 子版本里存在。读侧差异:`父 − 子` 恰好是 `{sourceResourceId}`,`子 − 父` 是 +/// `{replacementResourceId}`(当替换素材在父版本创建时就已登记、因此已在父绑定里时, +/// `子 − 父` 为空集)。 +/// **已知限制**:后一种情况下版本记录无法单独反推"是哪次替换摘掉了源素材",要无歧义地 +/// 持久化配对就得给 `GameIterationVersion` 增字段(跨端契约变更);本切片按 PRD §5.4 +/// 的版本字段表口径不新增字段,因此不声称这一点是完整的前后身份记录。 +/// +/// 只追加语义不变:本模块走 `mutate_manifest_at`(默认空放行集合),从不调用 +/// `mutate_manifest_at_allowing_version_removals`;写入边界继续由 `validate_game_iteration_versions` +/// 与 `validate_version_records_are_append_only` 拦截修改 / 删除 / 重排。 +const VERSION_REPLACEMENT_MAX_ID_CHARS: usize = 512; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ReadLocalProjectVersionReplacementCandidatesInput { + pub(crate) project_path: String, + pub(crate) source_version_id: String, + pub(crate) source_resource_id: String, +} + +/// PRD §5.3 的三项兼容性。三项必须同时为 `true` 才允许创建下一版本。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectVersionResourceCompatibility { + pub(crate) category_equal: bool, + pub(crate) subtype_equal: bool, + pub(crate) size_spec_equal: bool, +} + +impl ProjectVersionResourceCompatibility { + fn all_compatible(self) -> bool { + self.category_equal && self.subtype_equal && self.size_spec_equal + } + + /// 不兼容原因:按 PRD §5.3 的字段顺序报告第一项不等的维度。 + fn blocked_reason(self) -> Option<&'static str> { + if !self.category_equal { + Some("分类不同") + } else if !self.subtype_equal { + Some("类型不同") + } else if !self.size_spec_equal { + Some("尺寸规格不同") + } else { + None + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectVersionReplacementCandidate { + pub(crate) resource_id: String, + pub(crate) compatible: bool, + pub(crate) compatibility: ProjectVersionResourceCompatibility, + /// 不兼容原因;兼容时为 `null`。候选**渲染但禁用**,不用隐藏伪装成"素材不存在"。 + pub(crate) blocked_reason: Option<&'static str>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReadLocalProjectVersionReplacementCandidatesResult { + pub(crate) source_version_id: String, + pub(crate) source_resource_id: String, + pub(crate) candidates: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ReplaceLocalProjectVersionResourceInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_project_revision: u64, + pub(crate) source_version_id: String, + pub(crate) source_resource_id: String, + pub(crate) replacement_resource_id: String, +} + +/// PRD §5.3 的 `ProjectVersionResourceReplacement`。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectVersionResourceReplacement { + pub(crate) source_version_id: String, + pub(crate) source_resource_id: String, + pub(crate) replacement_resource_id: String, + pub(crate) compatibility: ProjectVersionResourceCompatibility, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReplaceLocalProjectVersionResourceResult { + pub(crate) version_id: String, + pub(crate) parent_version_id: String, + pub(crate) committed_project_revision: u64, + pub(crate) replacement: ProjectVersionResourceReplacement, +} + +/// 槽位 ID 恒等于 `asset:{resourceId}`(v3 恒等绑定口径),替换时必须同步换掉。 +fn version_binding_slot_id(resource_id: &str) -> String { + format!("asset:{resource_id}") +} + +/// 资源媒体格式身份。 +/// +/// 只做「声明值归一 + 缺声明时按扩展名回退」:去参数段(`; charset=...`)、去首尾空白、 +/// 转小写,并把 `image/jpg` 归到 canonical 的 `image/jpeg`(与 `assets.rs` 的 +/// `CanvasImageFormat::from_media_type` 同口径)。这里**不**做全量 MIME 规范化表 —— 尺寸规格 +/// 判据比较的是"格式身份",不是完整 MIME 语义。 +fn canonical_asset_media_format(asset: &GameCreationAppAssetManifestEntry) -> String { + let declared = asset + .media_type + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if !declared.is_empty() { + return if declared == "image/jpg" { + "image/jpeg".to_string() + } else { + declared + }; + } + Path::new(&asset.local_path) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .trim() + .to_ascii_lowercase() +} + +/// 已知尺寸事实:`imageSequenceFrames` 首帧的像素尺寸。没有帧事实时为 `None`。 +fn asset_known_frame_size(asset: &GameCreationAppAssetManifestEntry) -> Option<(u32, u32)> { + asset + .image_sequence_frames + .as_ref() + .and_then(|frames| frames.first()) + .map(|frame| (frame.width, frame.height)) +} + +/// 已知时长事实:仅序列资产有;其余类型为 `None`。 +fn asset_known_duration_ms(asset: &GameCreationAppAssetManifestEntry) -> Option { + asset.image_sequence_duration_ms +} + +/// PRD §5.3 的 `sizeSpecEqual`。 +/// +/// 判据 = 规范化媒体格式相等 **且**「任一方有事实的维度必须相等」(双方都无事实的维度不阻断)。 +/// +/// **诚实标注(刻意接受的降级)**:当前 manifest 资产表**没有** `width / height / durationMs` +/// 字段(`GameCreationAppAssetManifestEntry` 只有 `imageSequenceFrames[].width|height` 与 +/// `imageSequenceDurationMs`),而现役写入侧(上传、派生、画板回传、生成回流)几乎全部写 +/// `imageSequenceFrames: None`,所以这条判据在实际数据上**退化为"媒体格式相等"**: +/// `png ↔ webp` 会被判为尺寸规格不同而拒绝。 +/// +/// 这不是完整实现。若产品要求「同分类同类型、跨图片格式也能替换」,必须改走"给 manifest asset +/// 增 width / height / durationMs 并在写入侧回填"的方案(跨端契约变更),届时本判据改为消费落盘尺寸 +/// 事实并把"缺失即未知"的口径写进契约;在尺寸事实落盘之前,不得声称本项是完整尺寸规格比较。 +fn version_resource_size_spec_equal( + source: &GameCreationAppAssetManifestEntry, + replacement: &GameCreationAppAssetManifestEntry, +) -> bool { + let format_equal = canonical_asset_media_format(source) == canonical_asset_media_format(replacement); + let size_equal = match ( + asset_known_frame_size(source), + asset_known_frame_size(replacement), + ) { + (None, None) => true, + (source_size, replacement_size) => source_size == replacement_size, + }; + let duration_equal = match ( + asset_known_duration_ms(source), + asset_known_duration_ms(replacement), + ) { + (None, None) => true, + (source_duration, replacement_duration) => source_duration == replacement_duration, + }; + format_equal && size_equal && duration_equal +} + +/// 三项兼容性判据(后端是权威判据,前端只做呈现)。 +/// +/// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口); +/// - `subtypeEqual`:canonical `kind` 相等(别名表在 `shared-contracts`); +/// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`] 的降级标注。 +fn version_resource_compatibility( + source: &GameCreationAppAssetManifestEntry, + replacement: &GameCreationAppAssetManifestEntry, +) -> ProjectVersionResourceCompatibility { + ProjectVersionResourceCompatibility { + category_equal: game_creation_app_asset_category_with_read_time_healing( + source.category, + &source.kind, + ) == game_creation_app_asset_category_with_read_time_healing( + replacement.category, + &replacement.kind, + ), + subtype_equal: canonical_game_creation_app_asset_kind(&source.kind) + == canonical_game_creation_app_asset_kind(&replacement.kind), + size_spec_equal: version_resource_size_spec_equal(source, replacement), + } +} + +fn normalized_version_id<'a>(value: &'a str) -> Result<&'a str, String> { + let value = value.trim(); + if value.is_empty() { + return Err("sourceVersionId 不能为空".to_string()); + } + if value.chars().count() > VERSION_REPLACEMENT_MAX_ID_CHARS { + return Err("sourceVersionId 过长".to_string()); + } + Ok(value) +} + +fn normalized_resource_id<'a>(value: &'a str, label: &str) -> Result<&'a str, String> { + let value = value.trim(); + if value.is_empty() { + return Err(format!("{label} 不能为空")); + } + if value.chars().count() > VERSION_REPLACEMENT_MAX_ID_CHARS { + return Err(format!("{label} 过长")); + } + Ok(value) +} + +fn replacement_source_version<'a>( + manifest: &'a GameCreationAppManifest, + source_version_id: &str, +) -> Result<&'a GameIterationVersion, String> { + manifest + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .ok_or_else(|| format!("源项目版本不存在:{source_version_id}")) +} + +/// 源版本必须真的绑定着源素材(恒等绑定:`slotId` 与 `resourceId` 同时命中)。 +fn require_source_binding( + version: &GameIterationVersion, + source_resource_id: &str, +) -> Result<(), String> { + let slot_id = version_binding_slot_id(source_resource_id); + if version + .resource_bindings + .iter() + .any(|binding| binding.slot_id == slot_id && binding.resource_id == source_resource_id) + { + return Ok(()); + } + Err(format!( + "源版本未绑定该素材:{} · {}", + version.version_id, source_resource_id + )) +} + +fn manifest_asset<'a>( + manifest: &'a GameCreationAppManifest, + resource_id: &str, +) -> Result<&'a GameCreationAppAssetManifestEntry, String> { + manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| format!("项目资源不存在:{resource_id}")) +} + +/// 读取源素材在当前 manifest 里可用的替换候选,并给出后端权威兼容性结论。 +/// +/// 只读:不改 manifest、不推进 revision。候选按 `manifest.assets` 顺序返回,源素材自身排除。 +pub(crate) fn read_local_project_version_replacement_candidates_at( + root: &Path, + source_version_id: &str, + source_resource_id: &str, +) -> Result { + let source_version_id = normalized_version_id(source_version_id)?; + let source_resource_id = normalized_resource_id(source_resource_id, "sourceResourceId")?; + let manifest = read_existing_manifest_for_project(root)?; + let source_version = replacement_source_version(&manifest, source_version_id)?; + require_source_binding(source_version, source_resource_id)?; + let source_asset = manifest_asset(&manifest, source_resource_id)?; + + let candidates = manifest + .assets + .iter() + .filter(|asset| asset.id != source_resource_id) + .map(|asset| { + let compatibility = version_resource_compatibility(source_asset, asset); + LocalProjectVersionReplacementCandidate { + resource_id: asset.id.clone(), + compatible: compatibility.all_compatible(), + compatibility, + blocked_reason: compatibility.blocked_reason(), + } + }) + .collect(); + + Ok(ReadLocalProjectVersionReplacementCandidatesResult { + source_version_id: source_version_id.to_string(), + source_resource_id: source_resource_id.to_string(), + candidates, + }) +} + +/// 用 `replacement_resource_id` 替换源版本引用的 `source_resource_id`,追加下一迭代版本。 +/// +/// 语义与拒绝路径: +/// - 可运行版本不可变:既有版本记录一个字节都不改,新记录只能追加在末尾; +/// - 三项兼容性必须同时为 `true`,否则拒绝并给出不等维度,**不做假成功**; +/// - 源版本不存在 / 源版本未绑定该素材 / 替换素材未登记 / 替换素材与源素材相同 → 拒绝; +/// - CAS:`expectedProjectId` 与 `expectedProjectRevision` 必须与锁内读到的事实一致, +/// 任何拒绝都保证 manifest 与 revision 不变; +/// - 成功后推进一次项目 revision,且新版本的 `projectRevision` 必须等于推进后的值 +/// (`validate_game_iteration_versions` 要求子版本修订严格大于父版本)。 +pub(crate) fn replace_local_project_version_resource_at( + root: &Path, + expected_project_id: &str, + expected_project_revision: u64, + source_version_id: &str, + source_resource_id: &str, + replacement_resource_id: &str, +) -> Result { + if expected_project_revision + > shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + { + return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + } + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("替换素材 expectedProjectId 不能为空".to_string()); + } + let source_version_id = normalized_version_id(source_version_id)?.to_string(); + let source_resource_id = normalized_resource_id(source_resource_id, "sourceResourceId")?.to_string(); + let replacement_resource_id = + normalized_resource_id(replacement_resource_id, "replacementResourceId")?.to_string(); + if source_resource_id == replacement_resource_id { + return Err("替换素材与源素材相同".to_string()); + } + + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let _lock = acquire_project_write_lock(root, "asset.register")?; + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + + let target_revision = expected_project_revision + .checked_add(1) + .ok_or_else(|| "项目 revision 已达到上限".to_string())?; + + let mut appended: Option<(String, ProjectVersionResourceCompatibility)> = None; + mutate_manifest_at(root, |manifest| { + // 锁内复核 CAS:项目写锁已持有,此处再读一次 durable revision,把"读 revision → 写 manifest" + // 之间的窗口收干,任何漂移都在写入前失败关闭。 + if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + if manifest.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let source_version = replacement_source_version(manifest, &source_version_id)?; + let source_version_id_for_child = source_version.version_id.clone(); + let source_version_revision = source_version.project_revision; + if target_revision <= source_version_revision { + return Err(format!( + "替换版本 revision {target_revision} 必须大于源版本 revision {source_version_revision}" + )); + } + require_source_binding(source_version, &source_resource_id)?; + let source_asset = manifest_asset(manifest, &source_resource_id)?.clone(); + let replacement_asset = manifest_asset(manifest, &replacement_resource_id)?.clone(); + let compatibility = version_resource_compatibility(&source_asset, &replacement_asset); + if !compatibility.all_compatible() { + return Err(format!( + "resource-replacement-incompatible:{}", + compatibility + .blocked_reason() + .unwrap_or("替换兼容性未通过") + )); + } + + let source_slot_id = version_binding_slot_id(&source_resource_id); + let Some(source_index) = source_version + .resource_bindings + .iter() + .position(|binding| { + binding.slot_id == source_slot_id && binding.resource_id == source_resource_id + }) + else { + return Err(format!( + "源版本未绑定该素材:{source_version_id_for_child} · {source_resource_id}" + )); + }; + // 子版本绑定 = 父版本绑定去掉源素材,并保证替换素材在集合里。 + // + // 恒等绑定口径下,一个版本的 `resourceBindings` 是该版本**使用的素材集合** + // (创建时随清单冻结),不是"哪个位置用了它"的槽位表。所以"替换"落盘为: + // 源素材从这个集合里消失 + 替换素材出现在这个集合里。不能把源素材那条槽位改写成 + // 替换素材 —— 替换素材若在该版本创建时就已登记,它本来就已在集合中,改槽位会撞 + // 「资源槽位重复」。 + // + // 替换素材是版本创建之后才登记时(最常见的真实路径:先生成/上传了更好的素材再替换), + // 按源素材原来的位置插回,保持槽位顺序稳定,子父差异恰好一增一减。 + let mut resource_bindings: Vec = source_version + .resource_bindings + .iter() + .filter(|binding| { + !(binding.slot_id == source_slot_id && binding.resource_id == source_resource_id) + }) + .cloned() + .collect(); + if !resource_bindings + .iter() + .any(|binding| binding.resource_id == replacement_resource_id) + { + resource_bindings.insert( + source_index.min(resource_bindings.len()), + GameIterationVersionResourceBinding { + slot_id: version_binding_slot_id(&replacement_resource_id), + resource_id: replacement_resource_id.clone(), + }, + ); + } + + let version_id = format!("replace-{target_revision}"); + manifest.versions.push(GameIterationVersion { + version_id: version_id.clone(), + parent_version_id: Some(source_version_id_for_child), + project_revision: target_revision, + resource_bindings, + created_reason: GameIterationVersionCreatedReason::ResourceReplacement, + created_at: unix_timestamp(), + edit_prompt: None, + }); + appended = Some((version_id, compatibility)); + Ok(()) + })?; + + let committed_project_revision = advance_agent_runtime_project_revision_locked(root) + .map_err(|error| format!("替换版本已追加,但项目 revision 未能推进:{error}"))?; + let Some((version_id, compatibility)) = appended else { + return Err("替换版本写入未产生结果".to_string()); + }; + if committed_project_revision != target_revision { + return Err(format!( + "替换版本已追加,但项目 revision 推进结果与预期不一致:期望 {target_revision},实际 {committed_project_revision}" + )); + } + + Ok(ReplaceLocalProjectVersionResourceResult { + version_id, + parent_version_id: source_version_id.clone(), + committed_project_revision, + replacement: ProjectVersionResourceReplacement { + source_version_id, + source_resource_id, + replacement_resource_id, + compatibility, + }, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 0bac3d6cd..d78b0a4fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -6078,3 +6078,4 @@ mod response_stream; mod runtime_actions; mod runtime_state; mod sessions; +mod version_resource_replacement; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs new file mode 100644 index 000000000..96ead3c7e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs @@ -0,0 +1,812 @@ +use super::*; +use shared_contracts::game_creation_app::{ + GameCreationAppAssetCategory, GameCreationAppImageSequenceFrame, +}; + +/// 资源替换用例的项目夹具。 +fn replacement_project_fixture() -> PathBuf { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "资源替换口径测试").expect("init project"); + root +} + +fn write_replacement_fixture_file(root: &Path, relative_path: &str) { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create replacement fixture parent"); + } + fs::write(&path, relative_path.as_bytes()).expect("write replacement fixture asset"); +} + +fn replacement_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 资产 ID。 +fn register_replacement_fixture_asset( + root: &Path, + relative_path: &str, + kind: &str, + media_type: &str, + task_id: &str, +) -> String { + write_replacement_fixture_file(root, relative_path); + register_local_asset_at( + root, + relative_path, + kind, + media_type, + "generated", + replacement_generated_asset_source(task_id), + ) + .expect("register replacement fixture asset"); + read_manifest_for_project(root) + .expect("read manifest after registration") + .assets + .iter() + .find(|asset| asset.local_path == relative_path) + .expect("replacement fixture asset registered") + .id + .clone() +} + +fn replacement_manifest_path(root: &Path) -> PathBuf { + root.join(".agent/manifest.json") +} + +fn replacement_manifest_bytes(root: &Path) -> Vec { + fs::read(replacement_manifest_path(root)).expect("read installed manifest bytes") +} + +fn replacement_project_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision") + .revision +} + +/// 让当前 revision 成为一个正式版本,绑定当时 manifest 里的全部素材。 +fn register_replacement_initial_version(root: &Path) -> String { + let revision = + advance_agent_runtime_project_revision_locked(root).expect("advance project revision"); + assert!( + ensure_initial_game_iteration_version_at(root, revision).expect("create initial version"), + "initial version should be appended" + ); + read_manifest_for_project(root) + .expect("read manifest with initial version") + .versions + .last() + .expect("initial version exists") + .version_id + .clone() +} + +/// 直接改写 manifest(模拟存量数据 / 帧与时长事实),用于构造落盘字段。 +fn rewrite_replacement_manifest( + root: &Path, + mutate: impl FnOnce(&mut GameCreationAppManifest), +) -> GameCreationAppManifest { + let (path, mut manifest) = read_or_create_manifest(root).expect("read manifest for rewrite"); + mutate(&mut manifest); + write_manifest(&path, &manifest).expect("write rewritten manifest"); + manifest +} + +fn replace_at_current_revision( + root: &Path, + source_version_id: &str, + source_resource_id: &str, + replacement_resource_id: &str, +) -> Result { + replace_local_project_version_resource_at( + root, + "project-1", + replacement_project_revision(root), + source_version_id, + source_resource_id, + replacement_resource_id, + ) +} + +fn candidate_for<'a>( + result: &'a ReadLocalProjectVersionReplacementCandidatesResult, + resource_id: &str, +) -> &'a LocalProjectVersionReplacementCandidate { + result + .candidates + .iter() + .find(|candidate| candidate.resource_id == resource_id) + .unwrap_or_else(|| panic!("candidate missing: {resource_id}")) +} + +/// PRD §3.2 / §5.3 的主链路:替换 = 追加下一迭代版本 + 绑定集合按 1:1 交换,既有版本不可变。 +/// +/// 这条走最常见的真实路径:版本已经存在,用户之后才登记了更好的替换素材(因此替换素材不在父 +/// 版本绑定里)。子版本与父版本的差异恰好"一减一增"。 +#[test] +fn replacement_appends_next_iteration_version_and_keeps_existing_records_intact() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/hero-a.png", + "character", + "image/png", + "art-a", + ); + let untouched_asset = register_replacement_fixture_asset( + &root, + "assets/scene.png", + "scene", + "image/png", + "art-scene", + ); + let source_version_id = register_replacement_initial_version(&root); + // 版本创建之后才登记替换素材:它不在父版本绑定里,替换在绑定集合上就是一次 1:1 交换。 + let replacement_asset = register_replacement_fixture_asset( + &root, + "assets/hero-b.png", + "character", + "image/png", + "art-b", + ); + + let before = read_manifest_for_project(&root).expect("read manifest before replacement"); + let parent_version = before + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version exists") + .clone(); + assert!( + !parent_version + .resource_bindings + .iter() + .any(|binding| binding.resource_id == replacement_asset), + "夹具前提:替换素材不在父版本绑定里" + ); + let revision_before = replacement_project_revision(&root); + + let result = replace_at_current_revision( + &root, + &source_version_id, + &source_asset, + &replacement_asset, + ) + .expect("replace version resource"); + + assert_eq!(result.parent_version_id, source_version_id); + assert_eq!(result.committed_project_revision, revision_before + 1); + assert_eq!(result.version_id, format!("replace-{}", revision_before + 1)); + assert_eq!(result.replacement.source_resource_id, source_asset); + assert_eq!(result.replacement.replacement_resource_id, replacement_asset); + assert!( + result.replacement.compatibility.category_equal + && result.replacement.compatibility.subtype_equal + && result.replacement.compatibility.size_spec_equal, + "同分类同类型同格式必须三项兼容" + ); + + let after = read_manifest_for_project(&root).expect("read manifest after replacement"); + assert_eq!(after.versions.len(), before.versions.len() + 1); + // `after` 是重新从磁盘读回来的 manifest,所以这一条同时锁住「磁盘上的既有版本前缀逐项不变」。 + assert_eq!( + after.versions[..before.versions.len()], + before.versions[..], + "既有版本记录必须原样保留" + ); + + let child = after.versions.last().expect("child version appended"); + assert_eq!(child.version_id, result.version_id); + assert_eq!(child.parent_version_id.as_deref(), Some(source_version_id.as_str())); + assert_eq!( + child.created_reason, + GameIterationVersionCreatedReason::ResourceReplacement + ); + assert_eq!(child.project_revision, revision_before + 1); + assert!( + child.project_revision > parent_version.project_revision, + "子版本修订必须严格大于父版本" + ); + assert!(child.created_at >= parent_version.created_at); + assert!(child.edit_prompt.is_none()); + + // 绑定按 1:1 交换:长度不变、顺序不变,只把源素材换成替换素材。 + assert_eq!(child.resource_bindings.len(), parent_version.resource_bindings.len()); + for (index, parent_binding) in parent_version.resource_bindings.iter().enumerate() { + let child_binding = &child.resource_bindings[index]; + if parent_binding.resource_id == source_asset { + assert_eq!(child_binding.resource_id, replacement_asset); + assert_eq!(child_binding.slot_id, format!("asset:{replacement_asset}")); + } else { + assert_eq!(child_binding, parent_binding); + } + } + assert!( + child + .resource_bindings + .iter() + .all(|binding| binding.resource_id != source_asset), + "子版本不得继续引用源素材" + ); + assert!( + child + .resource_bindings + .iter() + .any(|binding| binding.resource_id == untouched_asset), + "未命中的绑定必须原样保留" + ); + // 源版本仍然是替换前的绑定(可运行版本不可变)。 + assert!( + parent_version + .resource_bindings + .iter() + .any(|binding| binding.resource_id == source_asset) + ); + + // 替换前后资源身份:父 − 子 = {source},子 − 父 = {replacement},各恰好一项(1:1 交换路径)。 + let child_ids: Vec<&str> = child + .resource_bindings + .iter() + .map(|binding| binding.resource_id.as_str()) + .collect(); + let parent_ids: Vec<&str> = parent_version + .resource_bindings + .iter() + .map(|binding| binding.resource_id.as_str()) + .collect(); + let added: Vec<&&str> = child_ids + .iter() + .filter(|resource_id| !parent_ids.contains(resource_id)) + .collect(); + let removed: Vec<&&str> = parent_ids + .iter() + .filter(|resource_id| !child_ids.contains(resource_id)) + .collect(); + assert_eq!(added, vec![&replacement_asset.as_str()]); + assert_eq!(removed, vec![&source_asset.as_str()]); + + // 素材表与磁盘文件都不因替换而变化。 + assert_eq!(after.assets, before.assets, "替换不得改动素材登记"); + assert!(root.join("assets/hero-a.png").is_file()); + assert!(root.join("assets/hero-b.png").is_file()); + + fs::remove_dir_all(root).ok(); +} + +/// 替换素材在源版本创建时就已登记(因此已在父绑定里)时:只摘掉源素材,不重复添加替换素材。 +/// +/// 恒等绑定口径下每个版本绑定的是"使用的素材集合",所以这条路径的子版本比父版本少一条绑定, +/// 且**不得**出现重复槽位(`validate_game_iteration_versions` 会拒绝重复 `slotId`)。 +#[test] +fn replacement_drops_source_binding_when_replacement_is_already_bound() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/legacy.png", + "character", + "image/png", + "art-legacy", + ); + let already_bound_replacement = register_replacement_fixture_asset( + &root, + "assets/final.png", + "character", + "image/png", + "art-final", + ); + let source_version_id = register_replacement_initial_version(&root); + let before = read_manifest_for_project(&root).expect("read manifest before replacement"); + let parent_version = before + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version exists") + .clone(); + assert!( + parent_version + .resource_bindings + .iter() + .any(|binding| binding.resource_id == already_bound_replacement), + "夹具前提:替换素材已在父版本绑定里" + ); + + replace_at_current_revision( + &root, + &source_version_id, + &source_asset, + &already_bound_replacement, + ) + .expect("替换素材已绑定时仍必须允许替换"); + + let after = read_manifest_for_project(&root).expect("read manifest after replacement"); + let child = after.versions.last().expect("child version appended"); + assert_eq!( + child.resource_bindings.len() + 1, + parent_version.resource_bindings.len(), + "替换素材已绑定时,子版本只比父版本少掉源素材这一条" + ); + assert!( + child + .resource_bindings + .iter() + .all(|binding| binding.resource_id != source_asset), + "源素材必须从该版本的绑定里消失" + ); + assert!( + child + .resource_bindings + .iter() + .any(|binding| binding.resource_id == already_bound_replacement), + "替换素材必须仍在绑定里" + ); + let mut slot_ids: Vec<&str> = child + .resource_bindings + .iter() + .map(|binding| binding.slot_id.as_str()) + .collect(); + slot_ids.sort_unstable(); + let unique = slot_ids.len(); + slot_ids.dedup(); + assert_eq!(slot_ids.len(), unique, "子版本绑定槽位不得重复"); + assert_eq!( + after.versions[..before.versions.len()], + before.versions[..], + "既有版本记录必须原样保留" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 三项兼容性逐项拒绝:任何一项不相等都不得创建下一版本,且 manifest 与 revision 都不变。 +#[test] +fn replacement_rejects_any_incompatible_dimension_and_writes_nothing() { + struct Case { + name: &'static str, + source_kind: &'static str, + source_media_type: &'static str, + target_kind: &'static str, + target_media_type: &'static str, + expected_reason: &'static str, + } + let cases = [ + Case { + name: "分类不同", + source_kind: "character", + source_media_type: "image/png", + target_kind: "scene", + target_media_type: "image/png", + expected_reason: "分类不同", + }, + Case { + name: "类型不同", + source_kind: "character", + source_media_type: "image/png", + target_kind: "character-animation", + target_media_type: "image/png", + expected_reason: "类型不同", + }, + Case { + name: "尺寸规格不同", + source_kind: "character", + source_media_type: "image/png", + target_kind: "character", + target_media_type: "image/webp", + expected_reason: "尺寸规格不同", + }, + ]; + + for case in cases { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/source.png", + case.source_kind, + case.source_media_type, + "art-source", + ); + let target_asset = register_replacement_fixture_asset( + &root, + "assets/target.png", + case.target_kind, + case.target_media_type, + "art-target", + ); + let source_version_id = register_replacement_initial_version(&root); + let bytes_before = replacement_manifest_bytes(&root); + let revision_before = replacement_project_revision(&root); + + let error = replace_at_current_revision(&root, &source_version_id, &source_asset, &target_asset) + .expect_err(&format!("{} 必须被拒绝", case.name)); + assert!( + error.contains(case.expected_reason), + "{} 的拒绝原因必须指出不等的维度,实际:{error}", + case.name + ); + + assert_eq!( + replacement_manifest_bytes(&root), + bytes_before, + "{} 被拒绝时 manifest 不得改动", + case.name + ); + assert_eq!( + replacement_project_revision(&root), + revision_before, + "{} 被拒绝时 revision 不得推进", + case.name + ); + + fs::remove_dir_all(root).ok(); + } +} + +/// 已知尺寸 / 时长事实存在时必须真正比较(不是只看格式)。 +#[test] +fn replacement_compares_known_frame_size_and_duration_facts_when_present() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/seq-source.png", + "character-animation", + "image/png", + "anim-source", + ); + let same_size_asset = register_replacement_fixture_asset( + &root, + "assets/seq-same.png", + "character-animation", + "image/png", + "anim-same", + ); + let other_size_asset = register_replacement_fixture_asset( + &root, + "assets/seq-other.png", + "character-animation", + "image/png", + "anim-other", + ); + let frame = |image_src: &str, width: u32, height: u32| GameCreationAppImageSequenceFrame { + image_src: image_src.to_string(), + object_key: None, + asset_object_id: None, + width, + height, + }; + rewrite_replacement_manifest(&root, |manifest| { + for asset in manifest.assets.iter_mut() { + let (width, height, duration) = if asset.id == other_size_asset { + (1024, 768, 900) + } else { + (800, 600, 600) + }; + asset.image_sequence_frames = Some(vec![frame("frame-0001.png", width, height)]); + asset.image_sequence_duration_ms = Some(duration); + } + }); + let source_version_id = register_replacement_initial_version(&root); + + let candidates = + read_local_project_version_replacement_candidates_at(&root, &source_version_id, &source_asset) + .expect("read candidates"); + assert!(candidate_for(&candidates, &same_size_asset).compatible); + let other = candidate_for(&candidates, &other_size_asset); + assert!(!other.compatible); + assert!(!other.compatibility.size_spec_equal); + assert_eq!(other.blocked_reason, Some("尺寸规格不同")); + + replace_at_current_revision(&root, &source_version_id, &source_asset, &same_size_asset) + .expect("帧尺寸与时长一致时允许替换"); + let error = + replace_at_current_revision(&root, &source_version_id, &source_asset, &other_size_asset) + .expect_err("帧尺寸与时长不一致时必须拒绝"); + assert!(error.contains("尺寸规格不同"), "unexpected error: {error}"); + + fs::remove_dir_all(root).ok(); +} + +/// 读时自愈口径与 `packages/shared` 对齐:落盘 `unclassified` 但 kind 能明确分类时按派生分类比较。 +/// +/// 这条同时是"两侧口径一致性"的锁:Rust 少掉 [`game_creation_app_asset_category_with_read_time_healing`] +/// 就会把历史误写的 UI 资产判成"分类不同",本用例立即转红。 +#[test] +fn replacement_heals_persisted_unclassified_category_like_the_shared_contract() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/ui-a.png", + "ui-design", + "image/png", + "ui-a", + ); + let healed_asset = register_replacement_fixture_asset( + &root, + "assets/ui-b.png", + "ui-design", + "image/png", + "ui-b", + ); + // 模拟存量误写:落盘值固化为 unclassified,而 kind 已能明确派生出 UI 交互分类。 + rewrite_replacement_manifest(&root, |manifest| { + for asset in manifest.assets.iter_mut() { + if asset.id == healed_asset { + asset.category = GameCreationAppAssetCategory::Unclassified; + } + } + }); + let persisted = read_manifest_for_project(&root).expect("read manifest after heal fixture"); + assert_eq!( + persisted + .assets + .iter() + .find(|asset| asset.id == healed_asset) + .expect("healed asset exists") + .category, + GameCreationAppAssetCategory::Unclassified, + "夹具必须真的落盘 unclassified" + ); + let source_version_id = register_replacement_initial_version(&root); + + let candidates = + read_local_project_version_replacement_candidates_at(&root, &source_version_id, &source_asset) + .expect("read candidates"); + let healed = candidate_for(&candidates, &healed_asset); + assert!( + healed.compatibility.category_equal, + "落盘 unclassified + kind ui-design 必须按读时自愈口径判为同分类" + ); + assert!(healed.compatible); + assert_eq!(healed.blocked_reason, None); + + let result = + replace_at_current_revision(&root, &source_version_id, &source_asset, &healed_asset) + .expect("healed category must allow replacement"); + assert!(result.replacement.compatibility.category_equal); + + fs::remove_dir_all(root).ok(); +} + +/// 换不到源版本 / 源素材不在该版本绑定里 / 目标未登记 / 目标就是源素材:全部拒绝且零副作用。 +#[test] +fn replacement_rejects_unresolvable_source_and_target() { + let root = replacement_project_fixture(); + let bound_asset = register_replacement_fixture_asset( + &root, + "assets/bound.png", + "character", + "image/png", + "art-bound", + ); + let source_version_id = register_replacement_initial_version(&root); + // 初始版本创建之后才登记的素材:不在该版本的绑定里。 + let unbound_asset = register_replacement_fixture_asset( + &root, + "assets/late.png", + "character", + "image/png", + "art-late", + ); + let revision_before = replacement_project_revision(&root); + let bytes_before = replacement_manifest_bytes(&root); + + let missing_version = replace_at_current_revision(&root, "absent-version", &bound_asset, &unbound_asset) + .expect_err("未知源版本必须被拒绝"); + assert!( + missing_version.contains("源项目版本不存在"), + "unexpected error: {missing_version}" + ); + + let unbound_source = replace_at_current_revision(&root, &source_version_id, &unbound_asset, &bound_asset) + .expect_err("源版本未绑定的素材必须被拒绝"); + assert!( + unbound_source.contains("源版本未绑定该素材"), + "unexpected error: {unbound_source}" + ); + + let unregistered_target = replace_at_current_revision( + &root, + &source_version_id, + &bound_asset, + "absent-asset", + ) + .expect_err("未登记的目标素材必须被拒绝"); + assert!( + unregistered_target.contains("项目资源不存在"), + "unexpected error: {unregistered_target}" + ); + + let same_target = replace_at_current_revision( + &root, + &source_version_id, + &bound_asset, + &bound_asset, + ) + .expect_err("目标与源相同必须被拒绝"); + assert!( + same_target.contains("替换素材与源素材相同"), + "unexpected error: {same_target}" + ); + + assert_eq!(replacement_manifest_bytes(&root), bytes_before); + assert_eq!(replacement_project_revision(&root), revision_before); + + fs::remove_dir_all(root).ok(); +} + +/// CAS:陈旧 revision 与跨项目身份都拒绝,manifest 与 revision 不变。 +#[test] +fn replacement_enforces_revision_and_identity_cas() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/cas-source.png", + "character", + "image/png", + "art-cas-source", + ); + let target_asset = register_replacement_fixture_asset( + &root, + "assets/cas-target.png", + "character", + "image/png", + "art-cas-target", + ); + let source_version_id = register_replacement_initial_version(&root); + let revision_before = replacement_project_revision(&root); + let bytes_before = replacement_manifest_bytes(&root); + + let stale = replace_local_project_version_resource_at( + &root, + "project-1", + revision_before + 1, + &source_version_id, + &source_asset, + &target_asset, + ) + .expect_err("陈旧 revision 必须被拒绝"); + assert_eq!(stale, "project-revision-conflict"); + + let identity = replace_local_project_version_resource_at( + &root, + "project-2", + revision_before, + &source_version_id, + &source_asset, + &target_asset, + ) + .expect_err("跨项目身份必须被拒绝"); + assert_eq!(identity, "project-identity-conflict"); + + assert_eq!(replacement_manifest_bytes(&root), bytes_before); + assert_eq!(replacement_project_revision(&root), revision_before); + + fs::remove_dir_all(root).ok(); +} + +/// 候选读取:源素材排除、按 manifest 顺序返回、逐项给出后端权威结论与不兼容原因;只读。 +#[test] +fn replacement_candidates_report_authoritative_compatibility() { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/pick-source.png", + "character", + "image/png", + "art-pick-source", + ); + let compatible_asset = register_replacement_fixture_asset( + &root, + "assets/pick-compatible.png", + "character", + "image/png", + "art-pick-compatible", + ); + let category_mismatch = register_replacement_fixture_asset( + &root, + "assets/pick-scene.png", + "scene", + "image/png", + "art-pick-scene", + ); + let subtype_mismatch = register_replacement_fixture_asset( + &root, + "assets/pick-animation.png", + "character-animation", + "image/png", + "art-pick-animation", + ); + let format_mismatch = register_replacement_fixture_asset( + &root, + "assets/pick-webp.webp", + "character", + "image/webp", + "art-pick-webp", + ); + let source_version_id = register_replacement_initial_version(&root); + let revision_before = replacement_project_revision(&root); + let bytes_before = replacement_manifest_bytes(&root); + + let result = + read_local_project_version_replacement_candidates_at(&root, &source_version_id, &source_asset) + .expect("read replacement candidates"); + + assert_eq!(result.source_version_id, source_version_id); + assert_eq!(result.source_resource_id, source_asset); + assert_eq!(result.candidates.len(), 4); + let ordered: Vec<&str> = result + .candidates + .iter() + .map(|candidate| candidate.resource_id.as_str()) + .collect(); + assert_eq!( + ordered, + vec![ + compatible_asset.as_str(), + category_mismatch.as_str(), + subtype_mismatch.as_str(), + format_mismatch.as_str(), + ], + "候选按 manifest.assets 顺序返回且排除源素材" + ); + + let compatible = candidate_for(&result, &compatible_asset); + assert!(compatible.compatible); + assert_eq!(compatible.blocked_reason, None); + assert!(compatible.compatibility.category_equal); + assert!(compatible.compatibility.subtype_equal); + assert!(compatible.compatibility.size_spec_equal); + + let category = candidate_for(&result, &category_mismatch); + assert!(!category.compatible); + assert!(!category.compatibility.category_equal); + assert_eq!(category.blocked_reason, Some("分类不同")); + + let subtype = candidate_for(&result, &subtype_mismatch); + assert!(!subtype.compatible); + assert!(subtype.compatibility.category_equal); + assert!(!subtype.compatibility.subtype_equal); + assert_eq!(subtype.blocked_reason, Some("类型不同")); + + let format = candidate_for(&result, &format_mismatch); + assert!(!format.compatible); + assert!(format.compatibility.category_equal); + assert!(format.compatibility.subtype_equal); + assert!(!format.compatibility.size_spec_equal); + assert_eq!(format.blocked_reason, Some("尺寸规格不同")); + + // 只读:不改 manifest、不推进 revision。 + assert_eq!(replacement_manifest_bytes(&root), bytes_before); + assert_eq!(replacement_project_revision(&root), revision_before); + + // 版本创建之后才登记的素材不在该版本绑定里:读它的候选必须失败关闭。 + let late_asset = register_replacement_fixture_asset( + &root, + "assets/pick-late.png", + "character", + "image/png", + "art-pick-late", + ); + let unbound = + read_local_project_version_replacement_candidates_at(&root, &source_version_id, &late_asset) + .expect_err("源版本未绑定的素材不能读候选:替换没有源绑定可言"); + assert!( + unbound.contains("源版本未绑定该素材"), + "unexpected error: {unbound}" + ); + + let blank_version = read_local_project_version_replacement_candidates_at(&root, " ", &source_asset) + .expect_err("空 sourceVersionId 必须被拒绝"); + assert!(blank_version.contains("sourceVersionId"), "unexpected error: {blank_version}"); + + fs::remove_dir_all(root).ok(); +} 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 ce5906c9d..32281512b 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -661,6 +661,32 @@ pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppA .unwrap_or(GameCreationAppAssetCategory::Unclassified) } +/// 资源分类的**读时自愈**口径。 +/// +/// 落盘 `category` 是权威值,唯一例外是自愈窗口:落盘值为 `Unclassified` 而该资产 `kind` +/// 能派生出明确的非 `Unclassified` 分类时采用派生值。这条规则用于修复历史上被系统误写成 +/// `unclassified` 的存量数据(典型例子:`kind:"ui"` 的 UI 资产曾因 kind 不在 canonical 目录 +/// 而落到 `image → unclassified`,修复别名后并不会自动归位,因为落盘值已固化)。 +/// +/// 与 `packages/shared/src/contracts/gameCreationApp.ts` 的 `gameCreationAppAssetCategory` +/// **逐分支一致**:那里的 `persisted === null`(缺字段 / 非法值)分支在 Rust 反序列化 +/// (`GameCreationAppAssetManifestEntry::deserialize`)已经按 kind 派生过,所以这里只补自愈那一支。 +/// `kind` 派生结果本身就是 `unclassified` 的(`image` / `video` / `code` / `publication-material`) +/// 不受影响,仍信任落盘值。 +pub fn game_creation_app_asset_category_with_read_time_healing( + category: GameCreationAppAssetCategory, + kind: &str, +) -> GameCreationAppAssetCategory { + let derived = game_creation_app_asset_category_for_kind(kind); + if category == GameCreationAppAssetCategory::Unclassified + && derived != GameCreationAppAssetCategory::Unclassified + { + derived + } else { + category + } +} + pub fn normalize_game_creation_app_asset_tags(tags: &[String]) -> Vec { let mut normalized = Vec::new(); for tag in tags { @@ -2128,6 +2154,53 @@ mod tests { ); } + /// 读时自愈只在「落盘 unclassified + kind 能派生出明确分类」这一个窗口生效,其余原样。 + /// + /// 与 `packages/shared/src/contracts/gameCreationApp.ts` 的 `gameCreationAppAssetCategory` + /// 对齐:显式非 unclassified 的落盘值即权威(哪怕与 kind 不符);kind 派生结果本身是 + /// unclassified 时也信任落盘值,所以 `image` / `video` / `code` / `unknown-kind` 不会被改写。 + #[test] + fn asset_category_read_time_healing_only_rewrites_misclassified_unclassified() { + for kind in ["ui-design", "ui", "icon", "icon-spritesheet"] { + assert_eq!( + game_creation_app_asset_category_with_read_time_healing( + GameCreationAppAssetCategory::Unclassified, + kind + ), + GameCreationAppAssetCategory::UiInteraction, + "{kind} 的历史误写 unclassified 必须自愈" + ); + } + assert_eq!( + game_creation_app_asset_category_with_read_time_healing( + GameCreationAppAssetCategory::Unclassified, + "character-art" + ), + GameCreationAppAssetCategory::Character + ); + for kind in ["image", "video", "code", "publication-material", "unknown-kind"] { + assert_eq!( + game_creation_app_asset_category_with_read_time_healing( + GameCreationAppAssetCategory::Unclassified, + kind + ), + GameCreationAppAssetCategory::Unclassified, + "{kind} 的派生结果就是 unclassified,不得改写落盘值" + ); + } + for (persisted, kind) in [ + (GameCreationAppAssetCategory::Character, "scene"), + (GameCreationAppAssetCategory::Audio, "character"), + (GameCreationAppAssetCategory::Document, "icon"), + ] { + assert_eq!( + game_creation_app_asset_category_with_read_time_healing(persisted, kind), + persisted, + "显式非 unclassified 的落盘值是权威值" + ); + } + } + #[test] fn asset_manifest_entry_defaults_category_and_tags_for_legacy_payloads() { let character = asset_entry_from_json(asset_entry_json("character"));