@@ -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 ,
} ,
} )
}