版本资源绑定升级为真实运行槽位
- 新增 game_iteration_resource_bindings:优先使用使用清单里的 slotId 到 resourceId 绑定,再为未被槽位覆盖的资源补 asset:{id} 恒等绑定
- 恒等绑定保留是有意的:历史版本与既有校验语义不受影响,同一资源被多个槽位使用时只取首个槽位,不产生冲突语义
- ensure_initial_game_iteration_version_at 与 append_agent_game_iteration_version_at 共用该构造函数
- 补测试:无使用清单时保持恒等绑定;登记运行槽位后槽位绑定优先且未覆盖资源仍有兜底
This commit is contained in:
@@ -718,6 +718,51 @@ pub(crate) fn read_existing_manifest_for_project(
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// 构造正式版本的资源绑定。
|
||||
///
|
||||
/// 绑定分两部分,顺序固定:
|
||||
///
|
||||
/// 1. **运行槽位绑定**:来自运行期资源使用清单的真实 `slotId → resourceId`。这是替换与
|
||||
/// 点选需要的语义——同一个资源在不同运行位置可以被单独替换。
|
||||
/// 2. **恒等绑定**:`asset:{id} → id`,与历史版本保持一致的兜底,保证未被运行槽位覆盖的
|
||||
/// 资源仍然有绑定,历史版本与旧校验语义不受影响。
|
||||
///
|
||||
/// 槽位绑定优先:同一资源若已被某个槽位绑定,不再为它补恒等绑定,避免同一资源在版本里
|
||||
/// 出现两份互相冲突的语义。
|
||||
pub(crate) fn game_iteration_resource_bindings(
|
||||
root: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
) -> Vec<GameIterationVersionResourceBinding> {
|
||||
let slot_bindings = read_local_project_asset_usage(root, Some(&manifest.project_id))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|usage| usage.slot_bindings())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut bound_resource_ids: Vec<String> = Vec::new();
|
||||
let mut bindings: Vec<GameIterationVersionResourceBinding> = Vec::new();
|
||||
for (slot_id, resource_id) in slot_bindings {
|
||||
if bound_resource_ids.iter().any(|id| id == &resource_id) {
|
||||
continue;
|
||||
}
|
||||
bound_resource_ids.push(resource_id.clone());
|
||||
bindings.push(GameIterationVersionResourceBinding {
|
||||
slot_id,
|
||||
resource_id,
|
||||
});
|
||||
}
|
||||
for asset in &manifest.assets {
|
||||
if bound_resource_ids.iter().any(|id| id == &asset.id) {
|
||||
continue;
|
||||
}
|
||||
bindings.push(GameIterationVersionResourceBinding {
|
||||
slot_id: format!("asset:{}", asset.id),
|
||||
resource_id: asset.id.clone(),
|
||||
});
|
||||
}
|
||||
bindings
|
||||
}
|
||||
|
||||
/// Registers the first formally playable project version after the current
|
||||
/// revision has produced a durable successful browser-playtest receipt.
|
||||
/// Replays are idempotent: once any formal version exists, validation never
|
||||
@@ -733,14 +778,7 @@ pub(crate) fn ensure_initial_game_iteration_version_at(
|
||||
if !manifest.versions.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let resource_bindings = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.map(|asset| GameIterationVersionResourceBinding {
|
||||
slot_id: format!("asset:{}", asset.id),
|
||||
resource_id: asset.id.clone(),
|
||||
})
|
||||
.collect();
|
||||
let resource_bindings = game_iteration_resource_bindings(root, &manifest);
|
||||
manifest.versions.push(GameIterationVersion {
|
||||
version_id: format!("initial-{project_revision}"),
|
||||
parent_version_id: None,
|
||||
@@ -779,14 +817,7 @@ pub(crate) fn append_agent_game_iteration_version_at(
|
||||
return Err("Agent 项目版本必须绑定大于 0 的项目 revision".to_string());
|
||||
}
|
||||
mutate_manifest_at(root, |manifest| {
|
||||
let resource_bindings = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.map(|asset| GameIterationVersionResourceBinding {
|
||||
slot_id: format!("asset:{}", asset.id),
|
||||
resource_id: asset.id.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let resource_bindings = game_iteration_resource_bindings(root, manifest);
|
||||
if let Some(existing) = manifest
|
||||
.versions
|
||||
.iter()
|
||||
|
||||
@@ -362,6 +362,116 @@ fn asset_usage_read_returns_none_when_absent() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 版本绑定升级:有运行槽位时用真实 `slotId → resourceId`,
|
||||
/// 未覆盖的资源补恒等绑定,保证历史语义与旧校验不受影响。
|
||||
#[test]
|
||||
fn game_iteration_bindings_prefer_runtime_slots_and_keep_identity_fallback() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "版本绑定升级测试").expect("init project");
|
||||
write_asset_usage_asset(&root, "assets/hero.png", b"hero");
|
||||
write_asset_usage_asset(&root, "memory/design.md", b"# design");
|
||||
register_local_asset_at(
|
||||
&root,
|
||||
"assets/hero.png",
|
||||
"character",
|
||||
"image/png",
|
||||
"generated",
|
||||
generated_asset_source("art-asset-plan"),
|
||||
)
|
||||
.expect("register hero asset");
|
||||
register_local_asset_at(
|
||||
&root,
|
||||
"memory/design.md",
|
||||
"document",
|
||||
"text/markdown",
|
||||
"generated",
|
||||
generated_asset_source("design-foundation"),
|
||||
)
|
||||
.expect("register design asset");
|
||||
let manifest = read_manifest_for_project(&root).expect("read manifest with registered assets");
|
||||
assert_eq!(manifest.assets.len(), 2, "fixture registers two assets");
|
||||
|
||||
// 无使用清单:绑定保持恒等,历史语义不变。
|
||||
let identity_bindings = game_iteration_resource_bindings(&root, &manifest);
|
||||
let identity_pairs: Vec<(String, String)> = identity_bindings
|
||||
.iter()
|
||||
.map(|binding| (binding.slot_id.clone(), binding.resource_id.clone()))
|
||||
.collect();
|
||||
assert_eq!(identity_pairs.len(), 2);
|
||||
for pair in &identity_pairs {
|
||||
assert_eq!(
|
||||
pair.0,
|
||||
format!("asset:{}", pair.1),
|
||||
"缺少使用清单时必须保持恒等绑定:{pair:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// 登记运行槽位后:槽位绑定优先,未覆盖资源仍补恒等绑定。
|
||||
let assets: Vec<LocalProjectAssetFacts> = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.map(|asset| LocalProjectAssetFacts {
|
||||
asset_id: asset.id.clone(),
|
||||
local_path: asset.local_path.clone(),
|
||||
})
|
||||
.collect();
|
||||
let hero_asset_id = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.local_path == "assets/hero.png")
|
||||
.expect("hero asset registered")
|
||||
.id
|
||||
.clone();
|
||||
|
||||
submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
&manifest.project_id,
|
||||
&assets,
|
||||
&asset_usage_submit(
|
||||
1,
|
||||
vec![
|
||||
asset_usage_reference(Some("hero"), Some(&hero_asset_id), None),
|
||||
asset_usage_reference(Some("title-hero"), Some(&hero_asset_id), None),
|
||||
],
|
||||
),
|
||||
)
|
||||
.expect("register runtime slots");
|
||||
|
||||
let slot_bindings = game_iteration_resource_bindings(&root, &manifest);
|
||||
let slot_pairs: Vec<(String, String)> = slot_bindings
|
||||
.iter()
|
||||
.map(|binding| (binding.slot_id.clone(), binding.resource_id.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
slot_pairs.len(),
|
||||
2,
|
||||
"同一资源的第二个槽位不重复绑定,且未覆盖资源保留恒等绑定:{slot_pairs:?}"
|
||||
);
|
||||
assert_eq!(slot_pairs[0].0, "hero");
|
||||
assert_eq!(slot_pairs[0].1, hero_asset_id);
|
||||
assert!(
|
||||
slot_pairs[1].0.starts_with("asset:"),
|
||||
"未被运行槽位覆盖的资源必须保留恒等绑定兜底:{slot_pairs:?}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
fn generated_asset_source(task_id: &str) -> GameCreationAppAssetSource {
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: Some(task_id.to_string()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 落盘正文只包含引用与诊断,不含 manifest 已有的资源元数据。
|
||||
#[test]
|
||||
fn asset_usage_persisted_payload_omits_manifest_owned_metadata() {
|
||||
|
||||
Reference in New Issue
Block a user