From fc4a2c12f486d6f6e06eee71bafd8db0f28ad626 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 17:37:01 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AGC=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=BA=A7=E8=B5=84=E6=BA=90=E6=9B=BF=E6=8D=A2=E7=9A=84?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=91=BD=E4=BB=A4=E4=B8=8E=E4=B8=89=E9=A1=B9?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 新增 game_creation_app_asset_category_with_read_time_healing:把 PRD §5.3「分类取值优先级」的读时自愈口径落到 Rust(落盘 unclassified 且 kind 能明确分类时采用派生值),与 packages/shared 的 gameCreationAppAssetCategory 逐分支一致,并补定向用例锁定该窗口 - 新增 project/version_resource_replacement.rs:三项兼容性判据(categoryEqual 用读时自愈口径、subtypeEqual 用 canonical kind、sizeSpecEqual 用规范化媒体格式 + 已知帧尺寸与时长事实) - sizeSpecEqual 在代码注释里明确标注降级:manifest 资产表今天没有 width/height/durationMs 字段,且现役写入侧几乎全部写 imageSequenceFrames=None,所以该项实际退化为「媒体格式相等」;要支持跨图片格式替换必须先给 manifest asset 加尺寸字段(跨端契约变更) - 新增 replace_local_project_version_resource_at:持项目写锁并按 expectedProjectId + expectedProjectRevision 做 CAS,一次写入里追加 createdReason=resource-replacement 的子版本(parentVersionId 指向源版本),子版本绑定 = 源版本绑定去掉源素材并保证替换素材在集合里;全程不调用 mutate_manifest_at_allowing_version_removals,既有版本记录一个字节不改 - 替换前后资源身份按 PRD §5.4 版本字段表口径用推导记录(父−子 = {源素材}、子−父 = {替换素材}),并注明「替换素材在源版本创建时就已登记」时子−父为空集的已知限制 - 新增 read_local_project_version_replacement_candidates_at:只读返回候选与后端权威兼容性结论,候选渲染但禁用并给出原因,不在前端重算判据 - commands.rs 新增两个命令包装(读用 asset.list、写用 asset.register),main.rs 注册进 generate_handler - 新增 8 条定向用例:只追加与父子/修订关系、两条绑定路径(1:1 交换与替换素材已绑定)、三项兼容性逐项拒绝且零副作用、CAS、四条拒绝路径、候选读取顺序与原因、读时自愈口径锁定 - 中间状态声明:本提交落地时前端调用方尚未提交,npm run ai-game-creator-shell:typecheck 会因 check-config.mjs 要求「每个 Tauri 命令都有 App invoke 调用方」而失败;这是刻意保留的中间状态,不得把这两个命令加进 native-only 白名单换绿 --- .../src-tauri/src/commands.rs | 36 + .../src-tauri/src/main.rs | 2 + .../src-tauri/src/project.rs | 2 + .../project/version_resource_replacement.rs | 482 +++++++++++ .../src-tauri/src/tests/mod.rs | 1 + .../src/tests/version_resource_replacement.rs | 812 ++++++++++++++++++ .../shared-contracts/src/game_creation_app.rs | 73 ++ 7 files changed, 1408 insertions(+) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs 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")); From 097d86d337b8142c7dd426f47f2519a036fd9003 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 17:42:00 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E6=8E=A5=E7=BA=BF=20AGC=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=BA=A7=E8=B5=84=E6=BA=90=E6=9B=BF=E6=8D=A2=E7=9A=84?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E9=93=BE=E8=B7=AF=E4=B8=8E=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 resourceVersionReplacementTransport.ts:两个 IPC 边界(read_..._candidates / replace_..._resource)只传一个 input 对象,不在客户端内执行时明确报错;这两个 invoke 调用点是 check-config.mjs「每个 Tauri 命令都要有 App invoke 调用方」的落点 - 新增 resourceVersionReplacementModel.ts:PRD §5.3 的 ProjectVersionResourceReplacement / 兼容性类型、禁用原因与失败文案映射、候选→弹窗素材映射;判据不在前端重算,只做呈现 - 候选→弹窗素材复用资源投影口径(resourceCanvasMediaType / resourceCanvasAssetKind),src 固定为空串,缩略图交给弹窗的 renderAssetMedia,不给 img 喂空 src - ImageCanvasProjectAssetPickerDialog 以 6 个可选 prop 扩展:singleSelect / assetBlockedReasons / renderAssetMedia / selectionNoun / errorMessage 与新的禁用原因文案;全部默认值保持网页端美术画布行为逐字不变(默认仍是多选、仍是 img、仍是「参考图」文案、不渲染禁用与错误区) - project-development/index.tsx 接线:状态与处理函数(读候选→开弹窗→CAS 写入→重读 manifest→selectActiveVersion),失败保留弹窗并显示原因,候选读取失败不弹空壳弹窗而是说明原因;本提交暂不放行工具条入口 - styles.css 增 .game-resource-replacement-media:候选行的类型占位样式,避免挂破图 --- .../resourceVersionReplacementModel.ts | 161 ++++++++++++++++ .../resourceVersionReplacementTransport.ts | 49 +++++ apps/ai-game-creator-shell/src/styles.css | 15 ++ .../src/view/project-development/index.tsx | 173 ++++++++++++++++++ .../ImageCanvasProjectAssetPickerDialog.tsx | 89 +++++++-- 5 files changed, 471 insertions(+), 16 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts new file mode 100644 index 000000000..018a378e4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts @@ -0,0 +1,161 @@ +import type { EditorAsset } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; +import { + resourceCanvasAssetKind, + resourceCanvasMediaType, +} from './resourceCanvasToolbarModel'; + +/** + * 「替换素材」的展示层口径:类型、禁用原因与失败文案。 + * + * 三项兼容性的**判据在 Rust**(`project/version_resource_replacement.rs`),前端只做呈现, + * 不在这里重算判据 —— 否则同一个规则会出现两份实现,迟早分叉。 + */ + +/** PRD §5.3 的三项兼容性;三项必须同时为 `true` 才能创建下一版本。 */ +export type ProjectVersionResourceCompatibility = { + categoryEqual: boolean; + subtypeEqual: boolean; + sizeSpecEqual: boolean; +}; + +export type LocalProjectVersionReplacementCandidate = { + resourceId: string; + compatible: boolean; + compatibility: ProjectVersionResourceCompatibility; + blockedReason: string | null; +}; + +export type ReadLocalProjectVersionReplacementCandidatesResult = { + sourceVersionId: string; + sourceResourceId: string; + candidates: LocalProjectVersionReplacementCandidate[]; +}; + +/** PRD §5.3 的 `ProjectVersionResourceReplacement`。 */ +export type ProjectVersionResourceReplacement = { + sourceVersionId: string; + sourceResourceId: string; + replacementResourceId: string; + compatibility: ProjectVersionResourceCompatibility; +}; + +export type ReplaceLocalProjectVersionResourceResult = { + versionId: string; + parentVersionId: string; + committedProjectRevision: number; + replacement: ProjectVersionResourceReplacement; +}; + +/** + * 三项兼容性对应的中文原因,按 PRD §5.3 的字段顺序取第一条不等的维度。 + * + * 后端已经给出 `blockedReason`;这里只在它缺失(旧后端 / 手工构造的候选)时按同一顺序派生, + * 不让界面出现"不可选但不说原因"的条目。 + */ +export function resourceReplacementBlockedReason( + candidate: LocalProjectVersionReplacementCandidate, +): string | null { + if (candidate.compatible) return null; + const reason = candidate.blockedReason?.trim(); + if (reason) return reason; + if (!candidate.compatibility.categoryEqual) return '分类不同'; + if (!candidate.compatibility.subtypeEqual) return '类型不同'; + if (!candidate.compatibility.sizeSpecEqual) return '尺寸规格不同'; + return '替换兼容性未通过'; +} + +/** + * 候选 → 禁用原因表,喂给弹窗的 `assetBlockedReasons`。 + * + * 不兼容候选**渲染但禁用**:隐藏会让用户以为"素材不存在",而真实原因是它不能替换这个素材。 + */ +export function resourceReplacementBlockedReasons( + candidates: readonly LocalProjectVersionReplacementCandidate[], +): Record { + const reasons: Record = {}; + for (const candidate of candidates) { + const reason = resourceReplacementBlockedReason(candidate); + if (reason) reasons[candidate.resourceId] = reason; + } + return reasons; +} + +/** + * 候选 → 弹窗素材。 + * + * `src` 固定为空字符串:AGC 的原生预览读取走带 scope 的调度器 + Blob URL,弹窗里拿不到同步 + * `src`,所以缩略图由调用方通过弹窗的 `renderAssetMedia` opt-in 渲染,**不给 `` 喂空串** + * (那会挂破图)。分类筛选复用语资源画布同一条媒体类型口径。 + */ +export function resourceReplacementPickerAssets( + resources: readonly ProjectResource[], + candidates: readonly LocalProjectVersionReplacementCandidate[], +): EditorAsset[] { + const resourceByAssetId = new Map(); + for (const resource of resources) { + const assetId = resource.manifestAssetId; + if (assetId && !resourceByAssetId.has(assetId)) { + resourceByAssetId.set(assetId, resource); + } + } + return candidates.flatMap((candidate) => { + const resource = resourceByAssetId.get(candidate.resourceId); + if (!resource) return []; + const assetKind = resourceCanvasAssetKind(resource); + return [ + { + id: candidate.resourceId, + label: resource.label, + src: '', + mediaType: resourceCanvasMediaType(resource) ?? 'image', + width: 0, + height: 0, + folderId: 'project-assets', + sourceKind: 'uploaded', + sourceType: 'generated', + persisted: true, + ...(assetKind ? { assetKind } : {}), + }, + ]; + }); +} + +/** 入口判据复用现役口径:`isResourceUsedByCurrentVersion`(manifest 身份 + 被当前版本绑定)。 */ + +/** + * 把 Rust 的结构化拒绝翻成用户可读中文。 + * + * 不认识的错误原样透出:宁可显示后端原文,也不把原因吞掉换成"替换失败"这种无信息文案。 + */ +export function resourceVersionReplacementErrorMessage(error: unknown): string { + const message = + error instanceof Error ? error.message : String(error ?? '').trim(); + if (!message) return '替换素材失败'; + if (message.includes('project-revision-conflict')) { + return '项目已被其它操作改动,请重试替换'; + } + if (message.includes('project-identity-conflict')) { + return '项目身份不一致,请重新打开项目后再试'; + } + if (message.includes('resource-replacement-incompatible')) { + const reason = message.split(':').pop()?.trim(); + return reason ? `替换素材不兼容:${reason}` : '替换素材不兼容'; + } + if (message.includes('源项目版本不存在')) { + return '当前版本已不存在,请刷新项目后重试'; + } + if (message.includes('源版本未绑定该素材')) { + return '该素材不在当前版本的绑定里,不能替换'; + } + if (message.includes('项目资源不存在')) { + return '替换素材未登记或已被删除'; + } + if (message.includes('替换素材与源素材相同')) { + return '替换素材与源素材相同'; + } + if (message.includes('需要在客户端内执行')) { + return '替换素材需要在客户端内执行'; + } + return message; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts new file mode 100644 index 000000000..c6f8abab3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts @@ -0,0 +1,49 @@ +import type { + ReadLocalProjectVersionReplacementCandidatesResult, + ReplaceLocalProjectVersionResourceResult, +} from './resourceVersionReplacementModel'; + +/** + * 版本级资源替换的 IPC 边界。 + * + * 两个命令都只吃单个 `input` 对象:Rust 侧入参是 `deny_unknown_fields` 的结构体, + * 多传字段会直接失败关闭。 + */ + +function replacementInvoke() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + // 与素材删除同口径:不在客户端内执行时明确报错,不做"看起来成功"的静默返回。 + throw new Error('替换素材需要在客户端内执行'); + } + return invoke; +} + +/** 读取源素材可用的替换候选与后端权威的三项兼容性结论(只读)。 */ +export async function readVersionResourceReplacementCandidates(input: { + projectPath: string; + sourceVersionId: string; + sourceResourceId: string; +}): Promise { + const invoke = replacementInvoke(); + return invoke( + 'read_local_project_version_resource_replacement_candidates', + { input }, + ); +} + +/** 改 manifest 绑定并追加下一迭代版本(不回滚:成功即已落盘)。 */ +export async function replaceVersionResource(input: { + projectPath: string; + expectedProjectId: string; + expectedProjectRevision: number; + sourceVersionId: string; + sourceResourceId: string; + replacementResourceId: string; +}): Promise { + const invoke = replacementInvoke(); + return invoke( + 'replace_local_project_version_resource', + { input }, + ); +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index eff4c5325..9af57283d 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6341,6 +6341,21 @@ iframe.preview-frame { color: #9b5537; } +/* + * 「替换素材」候选弹窗里的类型占位。 + * + * AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`, + * 所以候选行只渲染稳定类型图标,不挂 ``、不出现破图。 + */ +.game-resource-replacement-media { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #8d7a6b; +} + .game-attachment-errors { display: grid; gap: 4px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 9644a0850..746b01ce5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -41,6 +41,7 @@ import { Sparkles, Undo2, Users, + Video, X, ZoomOut, } from 'lucide-react'; @@ -74,6 +75,7 @@ import type { CanvasLayer, QuickEditPanelState, } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog'; import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; @@ -84,6 +86,7 @@ import { } from '../../features/project-workspace/LocalGamePreviewFrame'; import { dispatchResourceReferenceInsert, + resolveActiveIterationVersion, resourceReferenceCategoryLabel, } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; @@ -132,6 +135,16 @@ import { currentVersionResourceBindingIds, isResourceUsedByCurrentVersion, } from '../../features/resource-canvas/resourceCanvasVersionBindingModel'; +import { + resourceReplacementBlockedReasons, + resourceReplacementPickerAssets, + resourceVersionReplacementErrorMessage, + type LocalProjectVersionReplacementCandidate, +} from '../../features/resource-canvas/resourceVersionReplacementModel'; +import { + readVersionResourceReplacementCandidates, + replaceVersionResource, +} from '../../features/resource-canvas/resourceVersionReplacementTransport'; import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge'; import { currentPlatformSessionGeneration, @@ -1427,6 +1440,24 @@ export default function ProjectDevelopmentView({ const [resourceRenameError, setResourceRenameError] = useState( null, ); + /** + * 版本级资源替换(PRD §3.2 / §5.3):改 manifest 绑定,落盘为"新版本 + 新绑定"。 + * + * 源身份在打开弹窗时冻结(`sourceVersionId + sourceResourceId`),候选与兼容性结论全部来自 + * Rust;前端不重算判据,也不在失败时伪造成功。 + */ + const [resourceReplacementSource, setResourceReplacementSource] = useState<{ + versionId: string; + assetId: string; + } | null>(null); + const [resourceReplacementCandidates, setResourceReplacementCandidates] = + useState([]); + const [resourceReplacementOpen, setResourceReplacementOpen] = useState(false); + const [resourceReplacementLoading, setResourceReplacementLoading] = + useState(false); + const [resourceReplacementError, setResourceReplacementError] = useState< + string | null + >(null); const [pendingResourceEditActionIds, setPendingResourceEditActionIds] = useState>(() => new Set()); const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] = @@ -2578,6 +2609,121 @@ export default function ProjectDevelopmentView({ }, [reloadManifestAfterAssetCommand], ); + /** + * 打开「替换素材」:先向后端要一次权威候选,拿到了才开弹窗。 + * + * 只读失败(项目未初始化 / 源版本已不存在)时不弹空壳弹窗,直接把原因说明白, + * 避免出现"打开了但什么也选不了"的假入口。 + */ + const openResourceVersionReplacement = useCallback( + async (resource: ProjectResource) => { + const assetId = resource.manifestAssetId; + const version = resolveActiveIterationVersion( + projectVersions, + activeVersionId, + ); + if (!assetId || !version) return; + const source = { versionId: version.versionId, assetId }; + setResourceReplacementError(null); + setResourceReplacementCandidates([]); + setResourceReplacementSource(source); + setResourceReplacementLoading(true); + try { + const result = await readVersionResourceReplacementCandidates({ + projectPath, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + }); + setResourceReplacementCandidates(result.candidates); + setResourceReplacementOpen(true); + } catch (error) { + setResourceReplacementSource(null); + setResourceWorkbenchNotice( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [activeVersionId, projectPath, projectVersions], + ); + const closeResourceVersionReplacement = useCallback(() => { + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + setResourceReplacementError(null); + }, []); + /** + * 确认替换:CAS 写入「新版本 + 新绑定」,成功后重读 manifest 并把当前版本切到新版本。 + * + * 失败一律保留弹窗与选择并把原因显示在弹窗里:不切版本、不动高亮、不提示成功。 + * 按 PRD §3.2 末条,新版本不自动被运行中的预览消费;入口只在资源视图出现, + * 因此这里的 `selectActiveVersion` 不会顺手重载运行画面。 + */ + const confirmResourceVersionReplacement = useCallback( + async (assetIds: string[]) => { + const source = resourceReplacementSource; + const replacementResourceId = assetIds[0]; + if (!source || !replacementResourceId) { + setResourceReplacementError('请选择一个替换素材'); + return; + } + if (resourceReplacementLoading) return; + setResourceReplacementLoading(true); + setResourceReplacementError(null); + try { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error('替换素材需要在客户端内执行'); + } + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = await replaceVersionResource({ + projectPath, + expectedProjectId: manifest.projectId, + expectedProjectRevision: status.revision, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + replacementResourceId, + }); + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + selectActiveVersion(result.versionId); + await reloadManifestAfterAssetCommand( + result.committedProjectRevision, + `version-resource-replacement:${result.versionId}`, + ); + } catch (error) { + setResourceReplacementError( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [ + manifest.projectId, + projectPath, + reloadManifestAfterAssetCommand, + resourceReplacementLoading, + resourceReplacementSource, + ], + ); + const resourceReplacementPickerEntries = useMemo( + () => + resourceReplacementPickerAssets(resources, resourceReplacementCandidates), + [resourceReplacementCandidates, resources], + ); + const resourceReplacementBlockedReasonMap = useMemo( + () => resourceReplacementBlockedReasons(resourceReplacementCandidates), + [resourceReplacementCandidates], + ); /** * 素材重命名:只改磁盘文件名与 manifest 的 `localPath`,资产 id 不变。 * Rust 入参是 `deny_unknown_fields` 的结构体,这里必须只传这三个字段。 @@ -6405,6 +6551,33 @@ export default function ProjectDevelopmentView({ onConfirm={(newFileName) => void confirmResourceRename(newFileName)} /> ) : null} + {/* + 版本级资源替换:复用美术画布的参考图弹窗(单选 + 禁用原因 + 失败原因三个 opt-in prop)。 + 缩略图走 `renderAssetMedia` 的类型占位:AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL, + 弹窗里没有同步 `src`,直接给 `` 会挂破图。 + */} + ( + + )} + onCancel={closeResourceVersionReplacement} + onConfirm={(assetIds) => void confirmResourceVersionReplacement(assetIds)} + /> {resourceRecoveryPanelOpen ? (
void; onConfirm: (assetIds: string[]) => void; + /** + * 单选模式:点击即整组替换当前选择,不再渲染「已选」chip 行。 + * 默认 `false`,网页端美术画布的参考图多选行为逐字不变。 + */ + singleSelect?: boolean; + /** + * 被禁用的素材 id → 用户可见的禁用原因。默认空,即所有素材都可选。 + * + * 禁用项仍然渲染(不隐藏):隐藏会让用户以为"素材不存在",而真实原因是它不可替换。 + */ + assetBlockedReasons?: Readonly>; + /** + * 素材缩略图渲染器。默认 `undefined` → 沿用 ``。 + * + * 宿主(如 AGC 资源工作台)没有同步 `src` 时必须传它:AGC 的预览读取走带 scope 的 + * 原生调度器 + Blob URL,弹窗内取不到,直接给空 `src` 会挂破图。 + */ + renderAssetMedia?: (asset: EditorAsset) => ReactNode; + /** + * 选择对象的中文名词,用于拼弹窗标题与可访问名称。默认「参考图」, + * 即网页端美术画布的现有文案逐字不变。 + */ + selectionNoun?: string; + /** + * 宿主的失败原因(例如后端拒绝了这次替换)。默认 `undefined` → 不渲染。 + * + * 用于在弹窗内说明"为什么这次操作没成功",而不是静默关闭弹窗让用户以为成功了。 + */ + errorMessage?: string | null; }; function assetIcon(asset: EditorAsset) { @@ -46,6 +76,11 @@ export function ImageCanvasProjectAssetPickerDialog({ selectedAssetIds, onCancel, onConfirm, + singleSelect = false, + assetBlockedReasons, + renderAssetMedia, + selectionNoun = '参考图', + errorMessage, }: ImageCanvasProjectAssetPickerDialogProps) { const [query, setQuery] = useState(''); const [category, setCategory] = useState('all'); @@ -76,20 +111,24 @@ export function ImageCanvasProjectAssetPickerDialog({ }); function toggleAsset(assetId: string) { - setSelection((current) => - current.includes(assetId) + if (assetBlockedReasons?.[assetId]) return; + setSelection((current) => { + if (singleSelect) { + return current.includes(assetId) ? [] : [assetId]; + } + return current.includes(assetId) ? current.filter((item) => item !== assetId) - : [...current, assetId], - ); + : [...current, assetId]; + }); } return ( onConfirm(selection)} > 确认 @@ -126,13 +165,18 @@ export function ImageCanvasProjectAssetPickerDialog({ } > - {selectedAssets.length > 0 ? ( -
+ {errorMessage ? ( + + {errorMessage} + + ) : null} + {selectedAssets.length > 0 && !singleSelect ? ( +
{selectedAssets.map((asset) => ( ); })} From 0d0bba2ec2333d858085ff4cbc7338a450c0d8f1 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 17:51:52 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E6=94=BE=E8=A1=8C=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E5=8D=A1=E5=B7=A5=E5=85=B7=E6=9D=A1=E7=9A=84=E3=80=8C=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E7=B4=A0=E6=9D=90=E3=80=8D=E5=85=A5=E5=8F=A3=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=89=8D=E7=AB=AF=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资源卡选中工具条放行「替换素材」按钮:判据复用现役的 isResourceUsedByCurrentVersion(manifest 身份 + 被当前版本绑定),未绑定的素材一律不渲染,避免"点了没反应"的假按钮;本仓工具条 opt-in 硬约定要求真链路先通,因此入口放在最后一批提交 - 按钮落在宿主 extraActions,与「UI 编辑器 / 编辑标签 / 重命名」同处,不动 ImageCanvasSelectedLayerToolbarAction 共享 union、不改 resourceCanvasToolbarModel 的 supportedActions - selectActiveVersion 改为 useCallback 固定身份:替换成功后要走同一条记录层版本切换通道,普通函数声明会让 useCallback 依赖数组每次渲染都变(eslint react-hooks/exhaustive-deps 报错);替换相关的处理函数随之挪到它之后声明,依赖数组求值顺序才对 - 新增 tests/resourceVersionReplacement.test.tsx(真链路 4 条):入口只在被当前版本绑定时渲染、候选弹窗禁用不兼容项并显示原因且不合成非候选素材、写入 IPC 载荷逐字精确、成功后切版本但不重载预览(onPlay 不被调用)、后端拒绝时保留弹窗显示原因且不切版本不重读 manifest、候选读取失败时不弹空壳弹窗 - 新增 tests/resourceVersionReplacementModel.test.ts(8 条):禁用原因口径(后端原因优先、按 PRD §5.3 字段顺序派生、兜底不静默放行)、候选→弹窗素材映射(复用资源投影口径、不给 img 喂空 src、缺投影不合成条目)、失败文案逐条映射与未知错误原样透出、两个命令的 IPC 载荷形状 - 用例独立成文件,不往 tests/appSurface/project-development.suite.ts 里插,避免与工具条那条线互相踩 - 变异验证:放宽入口判据为"只要有 manifest 身份"→「不给假按钮」用例转红;失败路径改成静默关弹窗→「保留弹窗显示原因」用例转红;均已还原 --- .../src/view/project-development/index.tsx | 290 +++++----- .../tests/resourceVersionReplacement.test.tsx | 527 ++++++++++++++++++ .../resourceVersionReplacementModel.test.ts | 258 +++++++++ 3 files changed, 949 insertions(+), 126 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 746b01ce5..3a8dcc622 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -34,6 +34,7 @@ import { Play, Plus, Redo2, + Replace, RotateCcw, Search, Settings2, @@ -136,10 +137,10 @@ import { isResourceUsedByCurrentVersion, } from '../../features/resource-canvas/resourceCanvasVersionBindingModel'; import { + type LocalProjectVersionReplacementCandidate, resourceReplacementBlockedReasons, resourceReplacementPickerAssets, resourceVersionReplacementErrorMessage, - type LocalProjectVersionReplacementCandidate, } from '../../features/resource-canvas/resourceVersionReplacementModel'; import { readVersionResourceReplacementCandidates, @@ -2609,121 +2610,6 @@ export default function ProjectDevelopmentView({ }, [reloadManifestAfterAssetCommand], ); - /** - * 打开「替换素材」:先向后端要一次权威候选,拿到了才开弹窗。 - * - * 只读失败(项目未初始化 / 源版本已不存在)时不弹空壳弹窗,直接把原因说明白, - * 避免出现"打开了但什么也选不了"的假入口。 - */ - const openResourceVersionReplacement = useCallback( - async (resource: ProjectResource) => { - const assetId = resource.manifestAssetId; - const version = resolveActiveIterationVersion( - projectVersions, - activeVersionId, - ); - if (!assetId || !version) return; - const source = { versionId: version.versionId, assetId }; - setResourceReplacementError(null); - setResourceReplacementCandidates([]); - setResourceReplacementSource(source); - setResourceReplacementLoading(true); - try { - const result = await readVersionResourceReplacementCandidates({ - projectPath, - sourceVersionId: source.versionId, - sourceResourceId: source.assetId, - }); - setResourceReplacementCandidates(result.candidates); - setResourceReplacementOpen(true); - } catch (error) { - setResourceReplacementSource(null); - setResourceWorkbenchNotice( - resourceVersionReplacementErrorMessage(error), - ); - } finally { - setResourceReplacementLoading(false); - } - }, - [activeVersionId, projectPath, projectVersions], - ); - const closeResourceVersionReplacement = useCallback(() => { - setResourceReplacementOpen(false); - setResourceReplacementSource(null); - setResourceReplacementCandidates([]); - setResourceReplacementError(null); - }, []); - /** - * 确认替换:CAS 写入「新版本 + 新绑定」,成功后重读 manifest 并把当前版本切到新版本。 - * - * 失败一律保留弹窗与选择并把原因显示在弹窗里:不切版本、不动高亮、不提示成功。 - * 按 PRD §3.2 末条,新版本不自动被运行中的预览消费;入口只在资源视图出现, - * 因此这里的 `selectActiveVersion` 不会顺手重载运行画面。 - */ - const confirmResourceVersionReplacement = useCallback( - async (assetIds: string[]) => { - const source = resourceReplacementSource; - const replacementResourceId = assetIds[0]; - if (!source || !replacementResourceId) { - setResourceReplacementError('请选择一个替换素材'); - return; - } - if (resourceReplacementLoading) return; - setResourceReplacementLoading(true); - setResourceReplacementError(null); - try { - const invoke = window.__TAURI__?.core?.invoke; - if (!invoke) { - throw new Error('替换素材需要在客户端内执行'); - } - const status = await invoke<{ revision: number }>( - 'get_local_game_project_revision', - { projectPath }, - ); - if (!Number.isSafeInteger(status.revision) || status.revision < 0) { - throw new Error('项目 revision 无效'); - } - const result = await replaceVersionResource({ - projectPath, - expectedProjectId: manifest.projectId, - expectedProjectRevision: status.revision, - sourceVersionId: source.versionId, - sourceResourceId: source.assetId, - replacementResourceId, - }); - setResourceReplacementOpen(false); - setResourceReplacementSource(null); - setResourceReplacementCandidates([]); - selectActiveVersion(result.versionId); - await reloadManifestAfterAssetCommand( - result.committedProjectRevision, - `version-resource-replacement:${result.versionId}`, - ); - } catch (error) { - setResourceReplacementError( - resourceVersionReplacementErrorMessage(error), - ); - } finally { - setResourceReplacementLoading(false); - } - }, - [ - manifest.projectId, - projectPath, - reloadManifestAfterAssetCommand, - resourceReplacementLoading, - resourceReplacementSource, - ], - ); - const resourceReplacementPickerEntries = useMemo( - () => - resourceReplacementPickerAssets(resources, resourceReplacementCandidates), - [resourceReplacementCandidates, resources], - ); - const resourceReplacementBlockedReasonMap = useMemo( - () => resourceReplacementBlockedReasons(resourceReplacementCandidates), - [resourceReplacementCandidates], - ); /** * 素材重命名:只改磁盘文件名与 manifest 的 `localPath`,资产 id 不变。 * Rust 入参是 `deny_unknown_fields` 的结构体,这里必须只传这三个字段。 @@ -5122,16 +5008,141 @@ export default function ProjectDevelopmentView({ * * 画面本身不需要运行时按版本重映射:素材不可变(编辑产出新素材而不是改文件), * 所以版本之间没变的资源本来就是同一份文件,重载预览即可回到该版本对应的画面。 + * + * 用 `useCallback` 固定身份:素材替换成功后也要走这条记录层通道(见 + * `confirmResourceVersionReplacement`),普通函数声明会让依赖数组每次渲染都变。 */ - function selectActiveVersion(versionId: string) { - if (versionId === activeVersionId) { - return; - } - onActiveVersionChange?.(versionId); - if (mode === 'run' && runAvailable) { - onPlay?.(); - } - } + const selectActiveVersion = useCallback( + (versionId: string) => { + if (versionId === activeVersionId) { + return; + } + onActiveVersionChange?.(versionId); + if (mode === 'run' && runAvailable) { + onPlay?.(); + } + }, + [activeVersionId, mode, onActiveVersionChange, onPlay, runAvailable], + ); + /** + * 打开「替换素材」:先向后端要一次权威候选,拿到了才开弹窗。 + * + * 只读失败(项目未初始化 / 源版本已不存在)时不弹空壳弹窗,直接把原因说明白, + * 避免出现"打开了但什么也选不了"的假入口。 + * + * 本组处理函数放在 `selectActiveVersion` 之后:替换成功要走同一条记录层切换通道, + * 而 `useCallback` 的依赖数组在渲染期求值,声明顺序必须先于它。 + */ + const openResourceVersionReplacement = useCallback( + async (resource: ProjectResource) => { + const assetId = resource.manifestAssetId; + const version = resolveActiveIterationVersion( + projectVersions, + activeVersionId, + ); + if (!assetId || !version) return; + const source = { versionId: version.versionId, assetId }; + setResourceReplacementError(null); + setResourceReplacementCandidates([]); + setResourceReplacementSource(source); + setResourceReplacementLoading(true); + try { + const result = await readVersionResourceReplacementCandidates({ + projectPath, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + }); + setResourceReplacementCandidates(result.candidates); + setResourceReplacementOpen(true); + } catch (error) { + setResourceReplacementSource(null); + setResourceWorkbenchNotice( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [activeVersionId, projectPath, projectVersions], + ); + const closeResourceVersionReplacement = useCallback(() => { + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + setResourceReplacementError(null); + }, []); + /** + * 确认替换:CAS 写入「新版本 + 新绑定」,成功后重读 manifest 并把当前版本切到新版本。 + * + * 失败一律保留弹窗与选择并把原因显示在弹窗里:不切版本、不动高亮、不提示成功。 + * 按 PRD §3.2 末条,新版本不自动被运行中的预览消费;入口只在资源视图出现, + * 因此这里的 `selectActiveVersion` 不会顺手重载运行画面。 + */ + const confirmResourceVersionReplacement = useCallback( + async (assetIds: string[]) => { + const source = resourceReplacementSource; + const replacementResourceId = assetIds[0]; + if (!source || !replacementResourceId) { + setResourceReplacementError('请选择一个替换素材'); + return; + } + if (resourceReplacementLoading) return; + setResourceReplacementLoading(true); + setResourceReplacementError(null); + try { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error('替换素材需要在客户端内执行'); + } + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = await replaceVersionResource({ + projectPath, + expectedProjectId: manifest.projectId, + expectedProjectRevision: status.revision, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + replacementResourceId, + }); + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + selectActiveVersion(result.versionId); + await reloadManifestAfterAssetCommand( + result.committedProjectRevision, + `version-resource-replacement:${result.versionId}`, + ); + } catch (error) { + setResourceReplacementError( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [ + manifest.projectId, + projectPath, + reloadManifestAfterAssetCommand, + resourceReplacementLoading, + resourceReplacementSource, + selectActiveVersion, + ], + ); + const resourceReplacementPickerEntries = useMemo( + () => + resourceReplacementPickerAssets(resources, resourceReplacementCandidates), + [resourceReplacementCandidates, resources], + ); + const resourceReplacementBlockedReasonMap = useMemo( + () => resourceReplacementBlockedReasons(resourceReplacementCandidates), + [resourceReplacementCandidates], + ); function showRunView() { if (!runAvailable || uiEditorRoute) { @@ -5919,6 +5930,31 @@ export default function ProjectDevelopmentView({ 重命名 ) : null} + {/* + 「替换素材」只在真链路能跑通时才渲染:素材必须是 manifest 资产, + 且被**当前版本**绑定(`currentVersionBindingIds` 就是版本绑定口径的 + 唯一判定,见 `resourceCanvasVersionBindingModel.ts`)。不满足时不渲染 + 按钮,避免出现"点了没反应"的假按钮。 + */} + {selectedResource && + isResourceUsedByCurrentVersion( + selectedResource, + currentVersionBindingIds, + ) ? ( + } + onClick={() => + void openResourceVersionReplacement( + selectedResource, + ) + } + > + 替换素材 + + ) : null} } onOpenQuickEditPanel={openResourceQuickEditPanel} @@ -6576,7 +6612,9 @@ export default function ProjectDevelopmentView({ )} onCancel={closeResourceVersionReplacement} - onConfirm={(assetIds) => void confirmResourceVersionReplacement(assetIds)} + onConfirm={(assetIds) => + void confirmResourceVersionReplacement(assetIds) + } /> {resourceRecoveryPanelOpen ? (
) { + const resources = + (args?.resources as Array<{ resourceId: string }> | undefined) ?? []; + return { + resourceIds: resources.map(({ resourceId }) => resourceId), + referenceEdges: [], + taskFlows: [], + connectionIndex: resources.map(({ resourceId }) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resources.map(({ resourceId }) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +function replacementManifest() { + const manifest = createGameCreationAppManifest(PROJECT_ID, '替换素材测试'); + manifest.assets = [ + { + id: 'asset-legacy', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/legacy.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-final', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/final.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-scene', + kind: 'scene', + mediaType: 'image/png', + localPath: 'assets/scene.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-late', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/late.png', + source: { kind: 'generated' as const }, + }, + ]; + manifest.versions = [ + { + versionId: SOURCE_VERSION_ID, + parentVersionId: null, + projectRevision: 1, + resourceBindings: [ + { slotId: 'asset:asset-legacy', resourceId: 'asset-legacy' }, + { slotId: 'asset:asset-final', resourceId: 'asset-final' }, + { slotId: 'asset:asset-scene', resourceId: 'asset-scene' }, + ], + createdReason: 'initial', + createdAt: 1_700_000_000, + }, + ]; + return manifest; +} + +const REPLACEMENT_CANDIDATES = { + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + candidates: [ + { + resourceId: 'asset-final', + compatible: true, + compatibility: { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: true, + }, + blockedReason: null, + }, + { + resourceId: 'asset-scene', + compatible: false, + compatibility: { + categoryEqual: false, + subtypeEqual: false, + sizeSpecEqual: true, + }, + blockedReason: '分类不同', + }, + ], +}; + +const REPLACEMENT_RESULT = { + versionId: 'replace-6', + parentVersionId: SOURCE_VERSION_ID, + committedProjectRevision: 6, + replacement: { + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + replacementResourceId: 'asset-final', + compatibility: { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: true, + }, + }, +}; + +type RenderOptions = { + replacementCandidates?: () => Promise; + replacementWrite?: () => Promise; +}; + +let observer: ReturnType< + typeof installResourceCardIntersectionObserver +> | null = null; + +function renderReplacementWorkbench(options: RenderOptions = {}) { + observer = installResourceCardIntersectionObserver(); + const manifest = replacementManifest(); + const nextManifest = { + ...manifest, + versions: [ + ...manifest.versions, + { + versionId: 'replace-6', + parentVersionId: SOURCE_VERSION_ID, + projectRevision: 6, + resourceBindings: [ + { slotId: 'asset:asset-final', resourceId: 'asset-final' }, + { slotId: 'asset:asset-scene', resourceId: 'asset-scene' }, + ], + createdReason: 'resource-replacement' as const, + createdAt: 1_700_000_100, + }, + ], + }; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: [], + updatedAt: layoutRevision, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 12, + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', + }; + } + if (command === 'get_local_game_project_revision') { + return { revision: EXPECTED_REVISION }; + } + if ( + command === 'read_local_project_version_resource_replacement_candidates' + ) { + return options.replacementCandidates + ? options.replacementCandidates() + : REPLACEMENT_CANDIDATES; + } + if (command === 'replace_local_project_version_resource') { + return options.replacementWrite + ? options.replacementWrite() + : REPLACEMENT_RESULT; + } + if (command === 'get_local_game_manifest') { + return nextManifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + const onActiveVersionChange = vi.fn(); + const onPlay = vi.fn(); + const onManifestChange = vi.fn(); + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: PROJECT_PATH, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + activeVersionId: null, + onActiveVersionChange, + onPlay, + onManifestChange, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + return { invoke, onActiveVersionChange, onPlay, onManifestChange }; +} + +async function selectCardAndOpenToolbar(label: string) { + await waitFor(() => + expect( + document.querySelector('.game-resource-book-thumbnail'), + ).not.toBeNull(), + ); + // 浮出工具条只在栏目页(`resourceBookView !== 'main'`)上挂载:先打开素材所在的栏目页。 + if (!document.querySelector('[data-resource-book-view="child"]')) { + fireEvent.click( + await screen.findByRole('button', { name: '打开角色与对象' }), + ); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="child"]'), + ).not.toBeNull(), + ); + } + // 卡片必须先被 IntersectionObserver 报为可见,选中后才会浮出工具条; + // 这段 stub 与 `project-development.suite.ts` 的同名 helper 同形,本文件独立成文件后才复制过来。 + act(() => { + if (!observer) throw new Error('IntersectionObserver stub 未安装'); + observer.triggerVisible(); + }); + fireEvent.click(await findResourceSelectButton(label)); + return screen.findByRole('toolbar', { name: '图片工具栏' }); +} + +/** + * 资源卡预览用的 IntersectionObserver stub。 + * + * jsdom 没有 IntersectionObserver,而卡片浮出工具条依赖"可见"这一步,所以用例自己提供它。 + */ +function installResourceCardIntersectionObserver() { + const instances: Array<{ + callback: IntersectionObserverCallback; + observed: Set; + observer: IntersectionObserver; + }> = []; + + class ResourceCardIntersectionObserver { + readonly root = null; + readonly rootMargin = '160px'; + readonly thresholds = [0]; + readonly observed = new Set(); + + constructor(readonly callback: IntersectionObserverCallback) { + instances.push({ + callback, + observed: this.observed, + observer: this as unknown as IntersectionObserver, + }); + } + + observe(element: Element) { + this.observed.add(element); + } + + unobserve(element: Element) { + this.observed.delete(element); + } + + disconnect() { + this.observed.clear(); + } + + takeRecords() { + return []; + } + } + + Object.defineProperty(window, 'IntersectionObserver', { + configurable: true, + value: ResourceCardIntersectionObserver, + }); + + return { + triggerVisible(elements?: Element[]) { + const instance = instances.at(-1); + if (!instance) { + throw new Error('resource card IntersectionObserver was not created'); + } + const targets = elements ?? Array.from(instance.observed); + instance.callback( + targets.map( + (target) => + ({ + target, + isIntersecting: true, + intersectionRatio: 1, + }) as IntersectionObserverEntry, + ), + instance.observer, + ); + }, + }; +} + +describe('版本级资源替换', () => { + it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => { + const { invoke } = renderReplacementWorkbench(); + + // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 + const lateToolbar = await selectCardAndOpenToolbar('late.png'); + expect( + within(lateToolbar).queryByRole('button', { name: '替换素材' }), + ).toBeNull(); + expect( + within(lateToolbar).getByRole('button', { name: '快速编辑' }), + ).not.toBeNull(); + expect( + invoke.mock.calls.some( + ([command]) => + command === + 'read_local_project_version_resource_replacement_candidates', + ), + ).toBe(false); + + // 被当前版本绑定的素材:入口出现。 + const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); + expect( + within(sourceToolbar).getByRole('button', { name: '替换素材' }), + ).not.toBeNull(); + }); + + it('从入口一路走到写入:候选弹窗禁用不兼容项、写入载荷精确、成功后切版本但不重载预览', async () => { + const { invoke, onActiveVersionChange, onPlay, onManifestChange } = + renderReplacementWorkbench(); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + + await waitFor(() => + expect( + invoke.mock.calls.some( + ([command]) => + command === + 'read_local_project_version_resource_replacement_candidates', + ), + ).toBe(true), + ); + expect(invoke).toHaveBeenCalledWith( + 'read_local_project_version_resource_replacement_candidates', + { + input: { + projectPath: PROJECT_PATH, + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + }, + }, + ); + + const dialog = await screen.findByRole('dialog', { + name: '选择替换素材', + }); + // 候选只列后端给出的素材:未绑定/未登记的素材不合成条目。 + expect( + within(dialog).queryByRole('option', { name: '选择替换素材late.png' }), + ).toBeNull(); + const blockedOption = within(dialog).getByRole('option', { + name: '选择替换素材scene.png', + }) as HTMLButtonElement; + expect(blockedOption.disabled).toBe(true); + expect(within(dialog).getByText('分类不同')).not.toBeNull(); + const compatibleOption = within(dialog).getByRole('option', { + name: '选择替换素材final.png', + }) as HTMLButtonElement; + expect(compatibleOption.disabled).toBe(false); + + fireEvent.click(compatibleOption); + fireEvent.click( + within(dialog).getByRole('button', { name: '确认选择替换素材' }), + ); + + await waitFor(() => + expect( + invoke.mock.calls.some( + ([command]) => command === 'replace_local_project_version_resource', + ), + ).toBe(true), + ); + expect(invoke).toHaveBeenCalledWith( + 'replace_local_project_version_resource', + { + input: { + projectPath: PROJECT_PATH, + expectedProjectId: PROJECT_ID, + expectedProjectRevision: EXPECTED_REVISION, + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + replacementResourceId: 'asset-final', + }, + }, + ); + + // 成功后:重读 manifest 并按新 revision 提交;记录层当前版本切到新版本。 + await waitFor(() => + expect(onActiveVersionChange).toHaveBeenCalledWith('replace-6'), + ); + expect(onManifestChange).toHaveBeenCalledWith( + PROJECT_PATH, + expect.objectContaining({ projectId: PROJECT_ID }), + expect.objectContaining({ revision: 6, source: 'asset-command' }), + ); + // PRD §3.2 末条:替换不自动重载/重启运行中的预览。 + expect(onPlay).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(), + ); + }); + + it('后端拒绝时保留弹窗、显示原因,且不切版本、不重读 manifest', async () => { + const { invoke, onActiveVersionChange, onManifestChange } = + renderReplacementWorkbench({ + replacementWrite: async () => { + throw new Error('resource-replacement-incompatible:分类不同'); + }, + }); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + const dialog = await screen.findByRole('dialog', { + name: '选择替换素材', + }); + fireEvent.click( + within(dialog).getByRole('option', { name: '选择替换素材final.png' }), + ); + fireEvent.click( + within(dialog).getByRole('button', { name: '确认选择替换素材' }), + ); + + await waitFor(() => + expect(within(dialog).getByRole('alert').textContent).toBe( + '替换素材不兼容:分类不同', + ), + ); + expect(screen.getByRole('dialog', { name: '选择替换素材' })).not.toBeNull(); + expect(onActiveVersionChange).not.toHaveBeenCalled(); + expect(onManifestChange).not.toHaveBeenCalled(); + expect( + invoke.mock.calls.some( + ([command]) => command === 'get_local_game_manifest', + ), + ).toBe(false); + }); + + it('候选读取失败时不弹空壳弹窗,直接把原因说明白', async () => { + renderReplacementWorkbench({ + replacementCandidates: async () => { + throw new Error('源项目版本不存在:initial-1'); + }, + }); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + + await waitFor(() => + expect( + screen.getByText('当前版本已不存在,请刷新项目后重试'), + ).not.toBeNull(), + ); + expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts new file mode 100644 index 000000000..cd2890922 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts @@ -0,0 +1,258 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + LocalProjectVersionReplacementCandidate, + ProjectVersionResourceCompatibility, +} from '../src/features/resource-canvas/resourceVersionReplacementModel'; +import { + resourceReplacementBlockedReason, + resourceReplacementBlockedReasons, + resourceReplacementPickerAssets, + resourceVersionReplacementErrorMessage, +} from '../src/features/resource-canvas/resourceVersionReplacementModel'; +import { + readVersionResourceReplacementCandidates, + replaceVersionResource, +} from '../src/features/resource-canvas/resourceVersionReplacementTransport'; +import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel'; + +function candidate( + resourceId: string, + compatibility: Partial = {}, + overrides: Partial = {}, +): LocalProjectVersionReplacementCandidate { + const resolved: ProjectVersionResourceCompatibility = { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: true, + ...compatibility, + }; + return { + resourceId, + compatible: + resolved.categoryEqual && resolved.subtypeEqual && resolved.sizeSpecEqual, + compatibility: resolved, + blockedReason: null, + ...overrides, + }; +} + +function manifestWithAssets( + assets: Array<{ + id: string; + kind: string; + mediaType: string; + localPath: string; + }>, +): GameCreationAppManifest { + return { + schemaVersion: 'game-creator-manifest.v1', + projectId: 'project-1', + name: '资源替换模型测试', + goal: null, + tasks: [], + assets: assets.map((asset) => ({ + ...asset, + source: { kind: 'generated' as const }, + })), + }; +} + +describe('资源替换的展示层口径', () => { + it('兼容时没有禁用原因,不兼容时优先用后端给的原因', () => { + expect(resourceReplacementBlockedReason(candidate('asset-ok'))).toBeNull(); + expect( + resourceReplacementBlockedReason( + candidate( + 'asset-x', + { sizeSpecEqual: false }, + { blockedReason: '尺寸规格不同' }, + ), + ), + ).toBe('尺寸规格不同'); + }); + + it('后端没给原因时按 PRD §5.3 的字段顺序派生第一项不等的维度', () => { + expect( + resourceReplacementBlockedReason( + candidate('asset-category', { + categoryEqual: false, + subtypeEqual: false, + sizeSpecEqual: false, + }), + ), + ).toBe('分类不同'); + expect( + resourceReplacementBlockedReason( + candidate('asset-subtype', { + subtypeEqual: false, + sizeSpecEqual: false, + }), + ), + ).toBe('类型不同'); + expect( + resourceReplacementBlockedReason( + candidate('asset-size', { sizeSpecEqual: false }), + ), + ).toBe('尺寸规格不同'); + }); + + it('被标记为不兼容但三项都是 true 时仍给出兜底原因,不静默放行', () => { + expect( + resourceReplacementBlockedReason( + candidate('asset-unknown', {}, { compatible: false }), + ), + ).toBe('替换兼容性未通过'); + }); + + it('禁用原因表只收录被禁用的候选:兼容候选不得出现禁用文案', () => { + expect( + resourceReplacementBlockedReasons([ + candidate('asset-ok'), + candidate('asset-scene', { categoryEqual: false }), + ]), + ).toEqual({ 'asset-scene': '分类不同' }); + }); + + it('候选映射成弹窗素材:复用资源投影口径、不给 img 喂空 src、缺投影的候选不合成条目', () => { + const manifest = manifestWithAssets([ + { + id: 'asset-image', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + }, + { + id: 'asset-audio', + kind: 'background-music', + mediaType: 'audio/mpeg', + localPath: 'assets/theme.mp3', + }, + ]); + const resources = projectResourcesFromReadModels(manifest, [], []); + const assets = resourceReplacementPickerAssets(resources, [ + candidate('asset-image'), + candidate('asset-audio'), + candidate('asset-missing'), + ]); + + expect(assets.map((asset) => asset.id)).toEqual([ + 'asset-image', + 'asset-audio', + ]); + expect(assets.map((asset) => asset.label)).toEqual([ + 'hero.png', + 'theme.mp3', + ]); + expect(assets.map((asset) => asset.mediaType)).toEqual(['image', 'audio']); + // AGC 没有同步 src:交给弹窗的 renderAssetMedia 渲染类型占位,不能挂破图。 + expect(assets.every((asset) => asset.src === '')).toBe(true); + }); + + it('失败文案逐条可读,未知错误原样透出', () => { + expect( + resourceVersionReplacementErrorMessage( + new Error('project-revision-conflict'), + ), + ).toBe('项目已被其它操作改动,请重试替换'); + expect( + resourceVersionReplacementErrorMessage( + new Error('project-identity-conflict'), + ), + ).toBe('项目身份不一致,请重新打开项目后再试'); + expect( + resourceVersionReplacementErrorMessage( + new Error('resource-replacement-incompatible:尺寸规格不同'), + ), + ).toBe('替换素材不兼容:尺寸规格不同'); + expect( + resourceVersionReplacementErrorMessage( + new Error('源项目版本不存在:initial-1'), + ), + ).toBe('当前版本已不存在,请刷新项目后重试'); + expect( + resourceVersionReplacementErrorMessage( + new Error('源版本未绑定该素材:initial-1 · asset-1'), + ), + ).toBe('该素材不在当前版本的绑定里,不能替换'); + expect( + resourceVersionReplacementErrorMessage( + new Error('项目资源不存在:asset-x'), + ), + ).toBe('替换素材未登记或已被删除'); + expect( + resourceVersionReplacementErrorMessage(new Error('替换素材与源素材相同')), + ).toBe('替换素材与源素材相同'); + expect( + resourceVersionReplacementErrorMessage( + new Error('替换素材需要在客户端内执行'), + ), + ).toBe('替换素材需要在客户端内执行'); + expect(resourceVersionReplacementErrorMessage(new Error('磁盘满了'))).toBe( + '磁盘满了', + ); + expect(resourceVersionReplacementErrorMessage(undefined)).toBe( + '替换素材失败', + ); + }); +}); + +describe('资源替换的 IPC 边界', () => { + it('两个命令都只传一个 input 对象,字段名与 Rust 结构体逐字一致', async () => { + const invoke = vi.fn(async () => ({ ok: true })); + window.__TAURI__ = { core: { invoke } }; + + await readVersionResourceReplacementCandidates({ + projectPath: '/tmp/project', + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + }); + expect(invoke).toHaveBeenNthCalledWith( + 1, + 'read_local_project_version_resource_replacement_candidates', + { + input: { + projectPath: '/tmp/project', + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + }, + }, + ); + + await replaceVersionResource({ + projectPath: '/tmp/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + replacementResourceId: 'asset-target', + }); + expect(invoke).toHaveBeenNthCalledWith( + 2, + 'replace_local_project_version_resource', + { + input: { + projectPath: '/tmp/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + replacementResourceId: 'asset-target', + }, + }, + ); + }); + + it('不在客户端内执行时明确报错,不做静默成功', async () => { + (window as unknown as { __TAURI__?: unknown }).__TAURI__ = undefined; + await expect( + readVersionResourceReplacementCandidates({ + projectPath: '/tmp/project', + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + }), + ).rejects.toThrow('替换素材需要在客户端内执行'); + }); +}); From b1377572d6b756a834e2cbbd8fd093df9091993f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 18:12:41 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E6=A0=A1=E5=87=86=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E7=9A=84=20PRD=E3=80=81=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E3=80=81=E6=8A=80=E6=9C=AF=E6=96=B9=E6=A1=88?= =?UTF-8?q?=E4=B8=8E=E9=A1=B9=E7=9B=AE=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PRD §5.3 补实现口径:替换落盘 = 改绑定 + 追加新版本、子版本绑定 = 源绑定去掉源素材并保证替换素材在集合里、前后身份用推导记录、三项兼容性逐条判据与 sizeSpecEqual 的降级声明、失败即拒绝、入口与可见效果;同时标注 :364-373 的 ProjectResourceDescriptor 是历史四分类形状(其 width/height/durationMs 在 manifest 里并不存在) - PRD §5.4 前的版本章节与 §6 P1、§7.4 第 4 条按当前状态校准:替换已实施,但版本聚焦态仍不提供创建/替换/切换/回滚按钮,资源替换入口在资源卡工具条 - PRD 新增 §7.8 P1 资源替换验收六条(入口放行判据、候选禁用与原因、只追加写入形状、两条绑定路径、拒绝零副作用、成功后切版本但不重载预览) - 【测试用例】AGC资源工作台V3端到端验收:删掉「PRD L387 兼容性三项属 C6 取消范围」那句(改为明确不属于),更新 §7.3 工具条 opt-in 清单,补三条已知边界(运行画面不因改绑定而变、候选弹窗不加载缩略图、picker 在 AGC 首次使用),§8 覆盖与不覆盖范围补资源替换 - 【技术方案】AI游戏创作智能体App实施计划::669 的「本阶段不提供版本创建、替换、切换」与 :641 的布局切片范围按当前状态校准,并在文末新增「2026-09-11 AGC 资源工作台 V3:版本级资源替换」一节,写全命令、写入语义、前后身份推导口径、三项兼容性判据与降级声明、入口、成功后行为与验证数字 - decision-log 新增一条(背景 / 决策 / 恒等绑定硬约束 / 判据 / 降级 / 边界 / 影响范围 / 验证方式 / 关联文档):说明本次按 PRD §3.2 / §5.3 恢复「替换后创建下一迭代版本」,与 2026-09-10 那条「不创建新版本、替换功能整条取消」的口径关系,以及 C6 的候选/队列/审核仍不做 - Issue #309 的三处口径(C1 决策、贯穿性决策 6、验收总纲的「不创建新版本 / 不产生新版本」)按远程写确认规则**未擅自改动**,需要在用户确认后再改 --- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 30 ++++++++++++++++--- .../shared-memory/decision-log.md | 13 ++++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 15 ++++++++-- ...用例】AGC资源工作台V3端到端验收-2026-09-11.md | 14 +++++---- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index fd081e991..e90bacadc 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -187,10 +187,10 @@ idle -> focused(document|art|audio|version) -> idle - 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。聊天侧 `/read` 回执中的文件正文必须作为代码块渲染为 `
`,以便用户审阅源码字面量但不执行其中的 HTML;Markdown 渲染使用 `react-markdown` 的 `skipHtml`,依赖库对代码 span / fenced code 的文本转义;不得在整段 Markdown 上预转义 HTML,否则会把代码中的 `` 双重转义为字面量 `<tag>`。
 - 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
 - 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
-- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
+- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换(2026-09-11 实施)不在版本聚焦态提供,入口在资源卡选中工具条:选中被**当前版本**绑定的素材后替换成另一已登记素材,落盘为「追加下一迭代版本 + 新绑定」,详见 §3.2 / §5.3。
 - mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。
 - 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
-- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源编辑只允许追加继承源绑定并记录提示词的子版本,不允许原地替换或修改源版本。
+- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源编辑只允许追加继承源绑定并记录提示词的子版本,不允许原地替换或修改源版本;资源替换同样不改既有版本,而是追加一条 `resource-replacement` 子版本(§5.3)。
 - 资源聚焦不提供通用工具栏或工具侧边栏;图片聚焦态允许一个明确的“精修资源”业务动作进入素材创作无限画布,该动作不是在聚焦容器中内嵌编辑器或恢复通用工具栏。
 - 点击资源后,中央主视窗从 `resource-overview.list` 切换为 `resource-overview.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器以路径、类型、来源任务、依赖层级、同类型上下游和版本字段为首屏主体,不使用页面级浮层或可拖动标题栏。文档正文与按意图加载的音频控制位于元数据之后。
 - 焦点转换以稳定资源 ID 为准。只有从资源列表进入详情或从一个资源 ID 切换到另一个 ID 时聚焦详情 region;同一资源 ID 因 manifest 更新而重新投影时,不得抢走详情内音频 / 视频控件、文档链接或收起按钮的当前焦点。
@@ -384,8 +384,21 @@ type ProjectVersionResourceReplacement = {
 };
 ```
 
+> `ProjectResourceDescriptor.category` 是**历史形状**(旧四分类轴),已被本节上方 2026-09-10 / 2026-09-11 的六分类收口取代;实现以 manifest `assets[].category` 的六分类与读时自愈口径为准,本 DTO 只保留作名词参照。`width / height / durationMs` 也只在该历史形状里存在,manifest 资产表今天并没有这些字段(见下方 `sizeSpecEqual` 的降级声明)。
+
 三项兼容性必须同时为 true 才能创建下一版本。
 
+替换实现口径(2026-09-11):
+
+- **落盘 = 改绑定 + 追加新版本**:新版本 `createdReason='resource-replacement'`、`parentVersionId` 指向被替换的源版本;既有版本记录一个字节不改,走 §5.4 的只追加写入边界。子版本的 `resourceBindings` = 源版本绑定集合**去掉源素材**,并保证**替换素材在集合里**(替换素材是源版本创建之后才登记时,按源素材原来的位置插回);恒等绑定口径下不能把源素材那条槽位改写成替换素材,替换素材若在源版本创建时就已登记、本来就在集合里,改写会撞「资源槽位重复」。
+- **替换前后资源身份用推导记录**(不新增 manifest / 跨端契约字段,依据 §5.4 的版本字段表):父版本里有 `asset:{sourceResourceId}` 绑定是「替换前」,子版本里有 `asset:{replacementResourceId}` 绑定是「替换后」,配对由 `parentVersionId` + `createdReason` 确定;差异 `父 − 子 = {源素材}`,`子 − 父 = {替换素材}`(替换素材在源版本创建时就已登记时 `子 − 父` 为空集,此时版本记录无法单独反推配对)。
+- **三项兼容性判据**(后端权威,前端只呈现,不重算):
+  - `categoryEqual`:两侧资产的功能分类相等,用**读时自愈**口径(见本节 2026-09-11 收口)。Rust 侧与之逐分支一致的实现是 `game_creation_app_asset_category_with_read_time_healing`。
+  - `subtypeEqual`:两侧 `kind` 的 canonical 值相等(别名表见 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` / `canonical_game_creation_app_asset_kind`)。
+  - `sizeSpecEqual`:**规范化媒体格式相等,且「任一方有事实的维度必须相等」**(`imageSequenceFrames[0]` 的宽高、`imageSequenceDurationMs`;两个维度都无事实时不阻断)。**降级声明**:manifest 资产表没有 `width / height / durationMs` 字段,且现役写入侧(上传、派生、画板回传、生成回流)几乎全部写 `imageSequenceFrames: None`,因此该项**在真实数据上退化为「媒体格式相等」**(`png ↔ webp` 会判为不同而被拒绝)。这是刻意接受的最小实现;要支持跨图片格式替换,必须先给 manifest asset 增尺寸字段并在写入侧回填(跨端契约变更),届时把「缺失即未知」的口径写进契约。
+- **失败即拒绝**:任一项不为 true、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` CAS 冲突,都必须拒绝并说明原因;拒绝时 manifest 与项目 revision 都不变,界面不得出现成功态。
+- **入口与可见效果**:入口在资源卡选中工具条,只对「manifest 资产 + 被当前版本绑定」的素材放行;候选弹窗列出全部候选、不兼容项渲染但禁用并给出原因。替换成功后当前版本选择切到新版本,但**不自动重载 / 重启运行中的预览**(§3.2 末条);按 §7.4 的口径,改绑定不做运行时资源重映射,运行画面本身不会因改绑定而变化。
+
 ### 5.4 游戏迭代版本(P1)
 
 阶段六实现状态(2026-08-13 更新):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、静态检查、失败试玩或单独的 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮。自主首板只有在当前 revision 的 `preview.validate` 已成功形成持久试玩回执后,才幂等追加首条 `initial` 版本,并绑定当时 manifest 中全部已登记资源;同一完成态恢复不得重复创建。已有正式版本时,后续试玩通过不自动追加版本,仍由明确的资源派生事务创建子版本。
@@ -473,7 +486,7 @@ type ProjectAgentMudPointAttribution = {
 
 - 已实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复、自动协调 CAS 冲突处理与资源卡手动拖动。
 - 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成。
-- 已实施正式版本不可变模型、版本卡、父子关系与引用资源高亮;“编辑资源”可追加继承源绑定并记录提示词的子版本,资源直接替换、运行版本切换和兼容性迁移仍待后续切片。
+- 已实施正式版本不可变模型、版本卡、父子关系与引用资源高亮;“编辑资源”可追加继承源绑定并记录提示词的子版本;资源替换(2026-09-11)已按 §3.2 / §5.3 实施为「改绑定 + 追加 `resource-replacement` 子版本」,入口在资源卡选中工具条;运行版本切换(C7)已完成;兼容性迁移仍待后续切片。
 - 素材创作无限画布阶段一按权威专题一次交付图片导入、编辑、生成、导出、草稿恢复、正式本地回写、即时投影和焦点竞态闭环。
 - 高级抠图、图集、角色动画、视频时间线编辑和音频波形级编辑按后续切片实施;当前视频走源引用派生,音频只做语义重制。
 
@@ -530,7 +543,7 @@ type ProjectAgentMudPointAttribution = {
 1. manifest 缺少 `versions` 时旧项目正常打开且不显示伪造版本;存在合法记录时,固定“项目版本”分区按追加顺序显示稳定版本卡。
 2. 根版本、父版本和直接子版本关系在卡片或聚焦态可见;悬空父版本、自引用、重复 ID、非递增修订、倒退时间、重复 slot 和超限数字均失败关闭。
 3. 点击版本卡后,当前 manifest 中仍存在的绑定资产卡被高亮;历史已删除资产只在版本详情保留 ID,不创建幽灵卡,也不把 External Editor resource ID 猜成 manifest asset ID。
-4. 版本聚焦态展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定;“编辑资源”只追加继承源绑定并记录提示词的子版本,不提供原地替换、切换、回滚或运行按钮。
+4. 版本聚焦态展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定;“编辑资源”只追加继承源绑定并记录提示词的子版本,不提供原地替换、切换、回滚或运行按钮。资源替换**也不在版本聚焦态提供**:它的入口是资源卡选中工具条(§5.3),并且同样不原地改既有版本,而是追加一条新版本;本条限制的是版本聚焦态这个容器,不限制资源卡入口。
 5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;存储边界以跨进程专用锁串行覆盖旧状态读取、前缀校验、安装和回读,修改、删除、重排或并发旧快照覆盖已有版本时写入失败。
 6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revision;dependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。
 
@@ -582,6 +595,15 @@ type ProjectAgentMudPointAttribution = {
 8. 右侧正式钱包入口在普通工作台和 UI Editor 子路由都始终可见、键盘可达并能打开余额、充值与使用详情;路由切换不得使入口消失或失去交互。
 9. “资源依赖 / 资源类型”以连通分段按钮呈现,点击与键盘操作均只保留一个选中项;使用 Tab 定位和键盘切换时焦点指示清晰、完整,不被容器边界或 `overflow` 裁切。
 
+### 7.8 P1 资源替换验收
+
+1. 入口只对「manifest 资产 + 被当前版本绑定」的素材渲染;未绑定素材(版本创建后才登记、或不属于当前版本绑定集合)一律不出现入口,也不出现"点了没反应"的假按钮。
+2. 候选弹窗列出后端返回的全部候选,不兼容项**渲染但禁用**并显示不等维度(分类 / 类型 / 尺寸规格);不得隐藏不兼容候选,不得合成非候选素材,不得在前端重算兼容性判据。
+3. 写入后的 manifest:既有版本记录逐项不变(含 `projectRevision` / `resourceBindings` / `createdAt`),新版本追加在末尾,`createdReason='resource-replacement'`、`parentVersionId` 指向源版本、`projectRevision` 严格大于父版本且等于推进后的项目 revision。
+4. 子版本绑定 = 源版本绑定去掉源素材并保证替换素材在集合里;不得出现重复 `slotId`。替换素材在源版本创建后才登记时,子父差异恰好"一减一增"且顺序稳定。
+5. 三项兼容性任一为 false、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` 冲突,都必须在写入前拒绝;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的版本卡或新的"当前使用"高亮。
+6. 成功后重读 manifest 并把当前版本选择切到新版本;**不**自动重载或重启运行中的预览(§3.2 末条),也不做运行时资源重映射。可见变化只有:版本下拉多一条「资源替换 · 时间」、资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。
+
 ## 8. 非目标
 
 - 当前不开放“新增资源”产品入口。图片精修候选保持私有且不污染资源总览;“设为最终图”只允许保持原 asset ID、以新正式文件和事务方式切换 manifest 指针,不原地覆写旧文件。其他资源编辑继续追加派生 asset 或子版本。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 1432ae9e9..156567094 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -8364,3 +8364,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
 - 不做什么:不新增替代导航、不做栏目下拉/分段控件、不改 `resourceBookModel` 的 `main / child + category` 目标模型、不改每「排序模式 + 栏目」独立 viewport 的既有约定、不动 sidecar schema 与跨端契约。
 - 验证方式:新增反向守卫用例(`资源栏目大纲` 标签、`.game-resource-outline` 类名、对应 `