新增 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
@@ -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"));