资源替换改成直接替换:只改该版本绑定,不建新版本
- 口径变更:资源替换从「版本级替换(改绑定 + 追加下一迭代版本)」改成「直接替换(只改 manifest 里该版本的绑定,不建新版本)」,原因是用户 2026-09-11 的裁决「替换这块先做成直接替换」(同日 DDL)
- 按「四不写」删除版本级路径:不再创建 replace-{revision} 版本、不再写 parentVersionId / createdReason=resource-replacement、不再有「替换前后身份可推」的父子版本对;相关注释与常量一并删除,不留兼容分支
- 改绑定改走 manifest.rs 的窄放行通道 mutate_manifest_allowing_version_binding_rewrites(上一提交新增),放行集合固定为 [sourceVersionId]:不增删版本、不重排、只改这一个版本的 resourceBindings
- 绑定改写语义不变(恒等绑定口径):源素材从该版本的绑定集合里消失 + 保证替换素材在集合里;替换素材是版本创建后才登记时按源素材原位置插回,早已登记时只摘除(避免「资源槽位重复」)
- 准入校验按用户裁决调整:categoryEqual 与 subtypeEqual 仍是硬门禁(拒绝并说明哪一项不等),sizeSpecEqual 降级为提示不再拒绝 —— 它的完整判据今天不存在(manifest 无 width/height/durationMs,实际只等于媒体格式相等),硬拦会误拒 png↔webp 这类直接替换里最常见的需求;提示文案为「格式与源素材不同」,同时出现在候选与写入结果里
- 写入成功后推进一次项目 revision(改绑定属于 versions 变化,跨面快照门禁要求 revision 前进),并追加一条 asset.version_binding.replace 审计(复用既有 append_agent_db_record,字段 versionId / sourceResourceId / replacementResourceId / projectRevision);审计写失败报错但不回滚,与 asset.register 同口径
- 返回结构去掉 parentVersionId:改为 { versionId, committedProjectRevision, replacement: { versionId, sourceResourceId, replacementResourceId, compatibility, warning } }
- 定向用例按新形态重写并补齐 8 条:只改绑定不产生新版本且除绑定外逐字段不变、替换素材早已绑定时只摘除、分类/类型硬门禁拒绝且零副作用、跨格式只提示仍放行、已知帧尺寸与时长事实只提示、读时自愈口径、四条拒绝路径、CAS、候选读取顺序与原因、审计记录留痕
- 已知代价(写进模块文档):直接替换没有可回溯的替换历史,替换前身份只剩这条审计与 manifest 的 .previous 副本
- 验证:定向 8 passed / 0 failed;cargo check --locked --all-targets exit 0
This commit is contained in:
+153
-150
@@ -6,27 +6,35 @@ use shared_contracts::game_creation_app::{
|
||||
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
|
||||
/// 的版本字段表口径不新增字段,因此不声称这一点是完整的前后身份记录。
|
||||
/// 落盘语义:**改 manifest 里该版本的绑定,指向另一个已登记资源** ——
|
||||
/// 不追加新版本、不删除版本、不动资源文件、不建文件副本。
|
||||
///
|
||||
/// 只追加语义不变:本模块走 `mutate_manifest_at`(默认空放行集合),从不调用
|
||||
/// `mutate_manifest_at_allowing_version_removals`;写入边界继续由 `validate_game_iteration_versions`
|
||||
/// 与 `validate_version_records_are_append_only` 拦截修改 / 删除 / 重排。
|
||||
/// 写入通道是 `project/manifest.rs` 的 `mutate_manifest_allowing_version_binding_rewrites`:
|
||||
/// 它只放行"显式列出的版本改写自己的 `resourceBindings`",不增删、不重排、不改其它字段。
|
||||
/// 因此本命令既表达了"这个版本现在用另一个素材",又没有把版本记录的只追加保证打穿。
|
||||
///
|
||||
/// 恒等绑定口径(`slotId` 恒为 `asset:{resourceId}`)下的绑定改写 = **源素材从这个版本的绑定
|
||||
/// 集合里消失 + 替换素材出现在这个集合里**:
|
||||
/// - 替换素材是源版本创建之后才登记时,按源素材原来的位置插回,保持顺序稳定;
|
||||
/// - 替换素材早就登记(因此早就在集合里)时只做摘除 —— 不能"把源素材那条槽位改写成替换素材",
|
||||
/// 那会撞「资源槽位重复」。
|
||||
///
|
||||
/// 准入与提示:
|
||||
/// - **硬门禁**只有 `categoryEqual` 与 `subtypeEqual`(拦"把角色图换成背景音乐"这类明显错误);
|
||||
/// - `sizeSpecEqual` 只作为**提示**、不拒绝:它的完整判据今天不存在(manifest 资产表没有
|
||||
/// `width / height / durationMs`,实际只等于"媒体格式相等"),硬拦会误拒 `png ↔ webp` 这种
|
||||
/// 直接替换里最常见的正常需求。
|
||||
///
|
||||
/// 已知代价:直接替换形态下**没有可回溯的替换历史**。替换前的绑定身份只存在于
|
||||
/// ① `asset.version_binding.replace` 审计记录 ② manifest 的 `.previous` 恢复副本。
|
||||
/// 需要"某个版本历史上被换过几次、换成过什么"时要另立切片(例如 PRD §3.2 的版本级替换)。
|
||||
const VERSION_REPLACEMENT_MAX_ID_CHARS: usize = 512;
|
||||
|
||||
/// 直接替换的审计记录类型,与 `asset.register` / `asset.update` 同族。
|
||||
const VERSION_BINDING_REPLACE_RECORD_TYPE: &str = "asset.version_binding.replace";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct ReadLocalProjectVersionReplacementCandidatesInput {
|
||||
@@ -35,7 +43,7 @@ pub(crate) struct ReadLocalProjectVersionReplacementCandidatesInput {
|
||||
pub(crate) source_resource_id: String,
|
||||
}
|
||||
|
||||
/// PRD §5.3 的三项兼容性。三项必须同时为 `true` 才允许创建下一版本。
|
||||
/// 三项兼容性。只有前两项是硬门禁;`size_spec_equal` 仅用于提示。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProjectVersionResourceCompatibility {
|
||||
@@ -45,18 +53,21 @@ pub(crate) struct ProjectVersionResourceCompatibility {
|
||||
}
|
||||
|
||||
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> {
|
||||
/// 硬门禁:分类与类型。任一不等即拒绝替换。
|
||||
fn blocking_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
|
||||
}
|
||||
}
|
||||
|
||||
/// 提示:通过硬门禁、但尺寸规格判据不等时给出(不拒绝)。
|
||||
fn warning(self) -> Option<&'static str> {
|
||||
if self.blocking_reason().is_none() && !self.size_spec_equal {
|
||||
Some("格式与源素材不同")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -67,10 +78,13 @@ impl ProjectVersionResourceCompatibility {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LocalProjectVersionReplacementCandidate {
|
||||
pub(crate) resource_id: String,
|
||||
/// 能否选中:等价于"没有硬门禁原因"。
|
||||
pub(crate) compatible: bool,
|
||||
pub(crate) compatibility: ProjectVersionResourceCompatibility,
|
||||
/// 不兼容原因;兼容时为 `null`。候选**渲染但禁用**,不用隐藏伪装成"素材不存在"。
|
||||
/// 硬门禁原因;通过时为 `null`。候选**渲染但禁用**,不用隐藏伪装成"素材不存在"。
|
||||
pub(crate) blocked_reason: Option<&'static str>,
|
||||
/// 非阻断提示(当前只有"格式与源素材不同")。
|
||||
pub(crate) warning: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
@@ -92,26 +106,28 @@ pub(crate) struct ReplaceLocalProjectVersionResourceInput {
|
||||
pub(crate) replacement_resource_id: String,
|
||||
}
|
||||
|
||||
/// PRD §5.3 的 `ProjectVersionResourceReplacement`。
|
||||
/// 这一次替换的记录:哪个版本的哪个绑定指向改成了哪个素材。
|
||||
///
|
||||
/// 直接替换不产生新版本,所以没有 `parentVersionId`、也没有可推的前后版本对。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProjectVersionResourceReplacement {
|
||||
pub(crate) source_version_id: String,
|
||||
pub(crate) version_id: String,
|
||||
pub(crate) source_resource_id: String,
|
||||
pub(crate) replacement_resource_id: String,
|
||||
pub(crate) compatibility: ProjectVersionResourceCompatibility,
|
||||
pub(crate) warning: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[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 恒等绑定口径),替换时必须同步换掉。
|
||||
/// 槽位 ID 恒等于 `asset:{resourceId}`(v3 恒等绑定口径),绑定改写时同步维护。
|
||||
fn version_binding_slot_id(resource_id: &str) -> String {
|
||||
format!("asset:{resource_id}")
|
||||
}
|
||||
@@ -120,8 +136,7 @@ fn version_binding_slot_id(resource_id: &str) -> String {
|
||||
///
|
||||
/// 只做「声明值归一 + 缺声明时按扩展名回退」:去参数段(`; charset=...`)、去首尾空白、
|
||||
/// 转小写,并把 `image/jpg` 归到 canonical 的 `image/jpeg`(与 `assets.rs` 的
|
||||
/// `CanvasImageFormat::from_media_type` 同口径)。这里**不**做全量 MIME 规范化表 —— 尺寸规格
|
||||
/// 判据比较的是"格式身份",不是完整 MIME 语义。
|
||||
/// `CanvasImageFormat::from_media_type` 同口径)。**不**做全量 MIME 规范化表。
|
||||
fn canonical_asset_media_format(asset: &GameCreationAppAssetManifestEntry) -> String {
|
||||
let declared = asset
|
||||
.media_type
|
||||
@@ -159,24 +174,21 @@ fn asset_known_duration_ms(asset: &GameCreationAppAssetManifestEntry) -> Option<
|
||||
asset.image_sequence_duration_ms
|
||||
}
|
||||
|
||||
/// PRD §5.3 的 `sizeSpecEqual`。
|
||||
/// `sizeSpecEqual` 判据(**只提示、不拒绝**)。
|
||||
///
|
||||
/// 判据 = 规范化媒体格式相等 **且**「任一方有事实的维度必须相等」(双方都无事实的维度不阻断)。
|
||||
/// 判据 = 规范化媒体格式相等 **且**「任一方有事实的维度必须相等」(双方都无事实的维度不参与)。
|
||||
///
|
||||
/// **诚实标注(刻意接受的降级)**:当前 manifest 资产表**没有** `width / height / durationMs`
|
||||
/// 字段(`GameCreationAppAssetManifestEntry` 只有 `imageSequenceFrames[].width|height` 与
|
||||
/// `imageSequenceDurationMs`),而现役写入侧(上传、派生、画板回传、生成回流)几乎全部写
|
||||
/// `imageSequenceFrames: None`,所以这条判据在实际数据上**退化为"媒体格式相等"**:
|
||||
/// `png ↔ webp` 会被判为尺寸规格不同而拒绝。
|
||||
///
|
||||
/// 这不是完整实现。若产品要求「同分类同类型、跨图片格式也能替换」,必须改走"给 manifest asset
|
||||
/// 增 width / height / durationMs 并在写入侧回填"的方案(跨端契约变更),届时本判据改为消费落盘尺寸
|
||||
/// 事实并把"缺失即未知"的口径写进契约;在尺寸事实落盘之前,不得声称本项是完整尺寸规格比较。
|
||||
/// **诚实标注(刻意接受的降级)**:manifest 资产表没有 `width / height / durationMs` 字段,
|
||||
/// 且现役写入侧(上传、派生、画板回传、生成回流)几乎全部写 `imageSequenceFrames: None`,
|
||||
/// 所以这条判据在真实数据上**退化为"媒体格式相等"**。正因为它不完整,本轮**不用它拒绝**替换,
|
||||
/// 只在弹窗里提示"格式与源素材不同";要变成硬判据,必须先给 manifest asset 增尺寸字段并在写入侧
|
||||
/// 回填(跨端契约变更)。
|
||||
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 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),
|
||||
@@ -197,8 +209,8 @@ fn version_resource_size_spec_equal(
|
||||
/// 三项兼容性判据(后端是权威判据,前端只做呈现)。
|
||||
///
|
||||
/// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口);
|
||||
/// - `subtypeEqual`:canonical `kind` 相等(别名表在 `shared-contracts`);
|
||||
/// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`] 的降级标注。
|
||||
/// - `subtypeEqual`:canonical `kind` 相等(别名表在 `shared-contracts`)——以上两项是硬门禁;
|
||||
/// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`],只作提示。
|
||||
fn version_resource_compatibility(
|
||||
source: &GameCreationAppAssetManifestEntry,
|
||||
replacement: &GameCreationAppAssetManifestEntry,
|
||||
@@ -280,7 +292,7 @@ fn manifest_asset<'a>(
|
||||
.ok_or_else(|| format!("项目资源不存在:{resource_id}"))
|
||||
}
|
||||
|
||||
/// 读取源素材在当前 manifest 里可用的替换候选,并给出后端权威兼容性结论。
|
||||
/// 读取源素材在当前 manifest 里可用的替换候选,并给出后端权威的兼容性结论与提示。
|
||||
///
|
||||
/// 只读:不改 manifest、不推进 revision。候选按 `manifest.assets` 顺序返回,源素材自身排除。
|
||||
pub(crate) fn read_local_project_version_replacement_candidates_at(
|
||||
@@ -303,9 +315,10 @@ pub(crate) fn read_local_project_version_replacement_candidates_at(
|
||||
let compatibility = version_resource_compatibility(source_asset, asset);
|
||||
LocalProjectVersionReplacementCandidate {
|
||||
resource_id: asset.id.clone(),
|
||||
compatible: compatibility.all_compatible(),
|
||||
compatible: compatibility.blocking_reason().is_none(),
|
||||
compatibility,
|
||||
blocked_reason: compatibility.blocked_reason(),
|
||||
blocked_reason: compatibility.blocking_reason(),
|
||||
warning: compatibility.warning(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -317,16 +330,17 @@ pub(crate) fn read_local_project_version_replacement_candidates_at(
|
||||
})
|
||||
}
|
||||
|
||||
/// 用 `replacement_resource_id` 替换源版本引用的 `source_resource_id`,追加下一迭代版本。
|
||||
/// 把源版本引用的 `source_resource_id` **直接替换**成 `replacement_resource_id`。
|
||||
///
|
||||
/// 语义与拒绝路径:
|
||||
/// - 可运行版本不可变:既有版本记录一个字节都不改,新记录只能追加在末尾;
|
||||
/// - 三项兼容性必须同时为 `true`,否则拒绝并给出不等维度,**不做假成功**;
|
||||
/// - **不追加新版本**:只改该版本的绑定集合,其余版本整条不动、版本数量不变;
|
||||
/// - 硬门禁:分类不同 / 类型不同 → 拒绝并说明哪一项不等,**不做假成功**;
|
||||
/// - 源版本不存在 / 源版本未绑定该素材 / 替换素材未登记 / 替换素材与源素材相同 → 拒绝;
|
||||
/// - CAS:`expectedProjectId` 与 `expectedProjectRevision` 必须与锁内读到的事实一致,
|
||||
/// 任何拒绝都保证 manifest 与 revision 不变;
|
||||
/// - 成功后推进一次项目 revision,且新版本的 `projectRevision` 必须等于推进后的值
|
||||
/// (`validate_game_iteration_versions` 要求子版本修订严格大于父版本)。
|
||||
/// - 成功后推进一次项目 revision(改绑定属于 `versions` 变化,跨面快照门禁要求 revision 前进),
|
||||
/// 并追加一条 `asset.version_binding.replace` 审计。审计写失败会把错误报出,但 manifest 已落盘
|
||||
/// (与 `asset.register` 同口径,不做回滚)。
|
||||
pub(crate) fn replace_local_project_version_resource_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
@@ -345,7 +359,8 @@ pub(crate) fn replace_local_project_version_resource_at(
|
||||
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 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 {
|
||||
@@ -368,115 +383,103 @@ pub(crate) fn replace_local_project_version_resource_at(
|
||||
.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 mut applied: Option<ProjectVersionResourceCompatibility> = None;
|
||||
mutate_manifest_allowing_version_binding_rewrites(
|
||||
root,
|
||||
&|_manifest| vec![source_version_id.clone()],
|
||||
|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 version_index = manifest
|
||||
.versions
|
||||
.iter()
|
||||
.position(|version| version.version_id == source_version_id)
|
||||
.ok_or_else(|| format!("源项目版本不存在:{source_version_id}"))?;
|
||||
require_source_binding(&manifest.versions[version_index], &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 let Some(reason) = compatibility.blocking_reason() {
|
||||
return Err(format!("resource-replacement-incompatible:{reason}"));
|
||||
}
|
||||
|
||||
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<GameIterationVersionResourceBinding> = 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 slot_id = version_binding_slot_id(&source_resource_id);
|
||||
let version = &mut manifest.versions[version_index];
|
||||
let source_index = version
|
||||
.resource_bindings
|
||||
.iter()
|
||||
.position(|binding| {
|
||||
binding.slot_id == slot_id && binding.resource_id == source_resource_id
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!("源版本未绑定该素材:{source_version_id} · {source_resource_id}")
|
||||
})?;
|
||||
let mut resource_bindings: Vec<GameIterationVersionResourceBinding> = version
|
||||
.resource_bindings
|
||||
.iter()
|
||||
.filter(|binding| {
|
||||
!(binding.slot_id == 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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
version.resource_bindings = resource_bindings;
|
||||
applied = Some(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());
|
||||
.map_err(|error| format!("绑定已改写,但项目 revision 未能推进:{error}"))?;
|
||||
let Some(compatibility) = applied else {
|
||||
return Err("替换写入未产生结果".to_string());
|
||||
};
|
||||
if committed_project_revision != target_revision {
|
||||
return Err(format!(
|
||||
"替换版本已追加,但项目 revision 推进结果与预期不一致:期望 {target_revision},实际 {committed_project_revision}"
|
||||
"绑定已改写,但项目 revision 推进结果与预期不一致:期望 {target_revision},实际 {committed_project_revision}"
|
||||
));
|
||||
}
|
||||
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": VERSION_BINDING_REPLACE_RECORD_TYPE,
|
||||
"versionId": source_version_id,
|
||||
"sourceResourceId": source_resource_id,
|
||||
"replacementResourceId": replacement_resource_id,
|
||||
"projectRevision": committed_project_revision,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(ReplaceLocalProjectVersionResourceResult {
|
||||
version_id,
|
||||
parent_version_id: source_version_id.clone(),
|
||||
version_id: source_version_id.clone(),
|
||||
committed_project_revision,
|
||||
replacement: ProjectVersionResourceReplacement {
|
||||
source_version_id,
|
||||
version_id: source_version_id,
|
||||
source_resource_id,
|
||||
replacement_resource_id,
|
||||
compatibility,
|
||||
warning: compatibility.warning(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user