版本资源绑定升级为真实运行槽位

- 新增 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:
2026-09-10 10:13:46 +08:00
parent a571c6e225
commit d526439508
2 changed files with 157 additions and 16 deletions
@@ -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() {