新增 AGC 版本级资源替换的后端命令与三项兼容性判据
- 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 白名单换绿
This commit is contained in:
@@ -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<ReadLocalProjectVersionReplacementCandidatesResult, String> {
|
||||
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<ReplaceLocalProjectVersionResourceResult, String> {
|
||||
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 写失败
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<LocalProjectVersionReplacementCandidate>,
|
||||
}
|
||||
|
||||
#[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<u64> {
|
||||
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<ReadLocalProjectVersionReplacementCandidatesResult, String> {
|
||||
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<ReplaceLocalProjectVersionResourceResult, String> {
|
||||
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<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 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -6078,3 +6078,4 @@ mod response_stream;
|
||||
mod runtime_actions;
|
||||
mod runtime_state;
|
||||
mod sessions;
|
||||
mod version_resource_replacement;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String> {
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user