新增 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:
2026-09-11 17:37:01 +08:00
parent 3bfc3f0849
commit fc4a2c12f4
7 changed files with 1408 additions and 0 deletions
@@ -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"));