diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index b89050080..6b7091c3c 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -80,6 +80,8 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system 2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`. 3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceImageSrc` plus concrete `iconDescriptions`. +For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. + Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG. ## Scope Boundary diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json index c0acdb5eb..e16a9f558 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json @@ -1,11 +1,14 @@ { "id": "genarrative.agent-runtime", - "version": "2026-08-03.3", + "version": "2026-08-03.4", "sections": { "common": "common.md", "isolatedAgentContract": "isolated-agent-contract.md", "platformDefault": "platform/default.md", "platformLinux": "platform/linux.md", + "roles": { + "codePrototypeGameChat": "roles/code-prototype-game-chat.md" + }, "supervisor": { "intro": "supervisor/intro.md", "visualContractWithoutEditor": "supervisor/visual-contract-without-editor.md", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/code-prototype-game-chat.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/code-prototype-game-chat.md new file mode 100644 index 000000000..7c16a2da9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/code-prototype-game-chat.md @@ -0,0 +1,3 @@ +game-chat 使用素材完整快车道。必须先保护既有游戏语义和已完成产物:如果 observation 尚未包含 game/index.html 的当前正文与摘要,本响应第一步必须调用 file.read(path=game/index.html),不得猜测入口仍是初始化占位。只有文件缺失或正文与初始化页面一致、且当前 run 尚未写入项目时,才允许用一次 file.write 生成完整、自包含、可运行的 game/index.html;检测到非占位入口、已有可玩实现、当前 revision 已验证,或当前 run 已发生修改时,禁止整文件 file.write,必须保留原玩法,优先执行静态检查/试玩,确需修复时只能依据已读取的精确原文做最小 file.patch。不得把“继续”、continue 或其它纯续跑词当作游戏主题;缺少可继承的具体目标时必须失败关闭,不得另造新玩法。 + +HTML 必须满足固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局。assets/art-spec.png 只能作为视觉规范与派生参考,不得在运行时加载、铺作背景或裁切实体。必须等待已登记且透明有效的 assets/art-spritesheet.png 与 assets/art-spritesheet-slices/manifest.json,从切片清单按 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四种 usage 加载四个不同的独立透明素材,并在活动 Canvas 中通过 drawImage 绘制对应核心玩家、方块/目标、障碍/场景和反馈;不得猜测整张图集是等分网格,不得把整张图集作为 img、CSS background 或完整 drawImage 展示,也不得以纯代码几何替代核心实体。图集、四类切片或其可见使用任一缺失时不得交付。首次实现或最小修复后不要继续扩写功能;Runtime 会自动执行静态自检并在通过后立即试玩。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/visual-contract-with-editor.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/visual-contract-with-editor.md index b0727c8e7..7b24ba9b2 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/visual-contract-with-editor.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/visual-contract-with-editor.md @@ -1,3 +1,3 @@ -视觉产物始终按 owner 隔离:art-director 只声明 assets/art-spec.png,design-foundation 只声明 assets/ui-prototype.png,art-asset-plan 只声明 assets/manifest.art.json 与 assets/art-spritesheet.png;不得把 UI 与图集合并交给 art-director。旧派生图需要原位替换时,先在同一批次分别交给 design-foundation 与 art-asset-plan 建立精确原合同并取得 needs-repair,认领后再在同一批次分别发起各自唯一、完全继承原合同的 repair,两个 repair 共同构成一个显式视觉返工阶段。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。 +视觉产物始终按 owner 隔离:art-director 只声明 assets/art-spec.png,design-foundation 只声明 assets/ui-prototype.png,art-asset-plan 只声明 assets/manifest.art.json 与 assets/art-spritesheet.png;不得把 UI 与图集合并交给 art-director。art-spec 只作规范/reference;art-asset-plan 必须以它的稳定资源 ID 走 icon-spritesheet 生成透明图集并持久化服务端 iconImageSrcs,code-prototype 必须等待切片清单并使用玩家、目标、场景和反馈四类独立素材,不能猜测图集网格或跳过为纯代码核心画面。旧派生图需要原位替换时,先在同一批次分别交给 design-foundation 与 art-asset-plan 建立精确原合同并取得 needs-repair,认领后再在同一批次分别发起各自唯一、完全继承原合同的 repair,两个 repair 共同构成一个显式视觉返工阶段。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。 -art-director 是规范图产物型任务,expectedArtifacts 必须包含 assets/art-spec.png;design-foundation 是图片产物型任务,expectedArtifacts 必须包含 assets/ui-prototype.png;art-asset-plan 也是图片产物型任务,负责透明图集,expectedArtifacts 必须同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。三者都不能用空 expectedArtifacts 或纯文本回执代替图片。 +art-director 是规范图产物型任务,expectedArtifacts 必须包含 assets/art-spec.png;design-foundation 是图片产物型任务,expectedArtifacts 必须包含 assets/ui-prototype.png;art-asset-plan 也是图片产物型任务,负责透明图集,expectedArtifacts 必须同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。三者都不能用空 expectedArtifacts 或纯文本回执代替图片。code-prototype 必须依赖 art-asset-plan,并在活动 Canvas 中按 assets/art-spritesheet-slices/manifest.json 使用玩家、目标、场景和反馈四类独立切片;纯代码核心画面、猜测图集网格、整图展示和只引用路径均不满足交付。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index a643fc1d6..68665ace3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -14,7 +14,8 @@ mod tests; mod trace; pub(in crate::agent) use canvas_generation::{ - commit_prepared_platform_art_asset_at, platform_art_generation_error_needs_reconciliation, + commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, + platform_art_generation_error_needs_reconciliation, request_platform_art_asset_with_runtime_options_at, }; pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 6f7f7d55f..718bf8f31 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -634,6 +634,16 @@ pub(crate) async fn generate_platform_art_asset_at( .await } +struct PreparedPlatformArtAssetSlice { + name: String, + width: u32, + height: u32, + download: CanvasResourceDownload, + resource_id: Option, + asset_object_id: Option, + extension: String, +} + pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { requested_output_path: Option, replacement_fingerprint: Option, @@ -648,12 +658,19 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { provider: Option, warning: Option, slice_warning: Option, + slices: Vec, generation_route: String, generation_kind: String, reference_resource_ids: Vec, extension: String, } +impl PreparedPlatformArtAssetGeneration { + pub(in crate::agent) fn slice_count(&self) -> usize { + self.slices.len() + } +} + fn canonical_art_spec_reference_at( root: &Path, expected_canvas_project_id: &str, @@ -700,10 +717,10 @@ fn canonical_art_spec_reference_at( fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { let project_context = truncate_inline(prompt.trim(), 320); [ - "当前玩法的玩家主体、朝向或动作状态与可组合部件", - "当前玩法的目标物、收集物、敌对实体或危险物", - "当前玩法需要的地块、障碍、资源物件与场景装饰", - "得分、受击、成长、失败、胜利与操作反馈特效", + "第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材", + "第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材", + "第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材", + "第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材", ] .into_iter() .map(|category| format!("{category};遵循同一项目视觉规范:{project_context}")) @@ -742,6 +759,87 @@ fn platform_art_generation_warning(generated: &serde_json::Value) -> Option Result, String> { + let Some(icons) = generated + .get("iconImageSrcs") + .and_then(serde_json::Value::as_array) + else { + return Ok(Vec::new()); + }; + if icons.len() > 64 { + return Err("External Editor 返回的图集切片超过 64 个,已拒绝同步".to_string()); + } + let mut prepared = Vec::with_capacity(icons.len()); + let mut total_download_bytes = 0usize; + for (index, icon) in icons.iter().enumerate() { + let resource = icon + .get("resource") + .filter(|value| value.is_object()) + .unwrap_or(icon); + let download = resolve_canvas_resource_download(client, api_base_url, api_key, resource) + .await? + .ok_or_else(|| format!("平台图集第 {} 个切片缺少可下载图片", index + 1))?; + total_download_bytes = total_download_bytes + .checked_add(download.bytes.len()) + .ok_or_else(|| "平台图集切片累计大小溢出".to_string())?; + if total_download_bytes > 32 * 1024 * 1024 { + return Err("External Editor 返回的图集切片累计超过 32 MiB,已拒绝同步".to_string()); + } + let decoded = image::load_from_memory(&download.bytes) + .map_err(|error| format!("平台图集第 {} 个切片无法解码:{error}", index + 1))?; + let declared_width = icon + .get("width") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(decoded.width()); + let declared_height = icon + .get("height") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(decoded.height()); + if declared_width != decoded.width() || declared_height != decoded.height() { + return Err(format!( + "平台图集第 {} 个切片尺寸与响应不一致:declared={}x{} actual={}x{}", + index + 1, + declared_width, + declared_height, + decoded.width(), + decoded.height() + )); + } + let source_hint = json_string_field(resource, "objectKey") + .or_else(|| json_string_field(resource, "imageSrc")) + .or_else(|| json_string_field(icon, "objectKey")) + .or_else(|| json_string_field(icon, "imageSrc")); + let extension = infer_file_extension(source_hint.as_deref(), &download.media_type); + if extension != "png" { + return Err(format!( + "平台图集第 {} 个切片不是 PNG,已拒绝写入游戏切片合同", + index + 1 + )); + } + prepared.push(PreparedPlatformArtAssetSlice { + name: json_string_field(icon, "name") + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| format!("素材 {}", index + 1)), + width: decoded.width(), + height: decoded.height(), + resource_id: json_string_field(resource, "resourceId") + .or_else(|| json_string_field(icon, "resourceId")), + asset_object_id: json_string_field(resource, "assetObjectId") + .or_else(|| json_string_field(icon, "assetObjectId")), + extension: extension.to_string(), + download, + }); + } + Ok(prepared) +} + pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -1085,6 +1183,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .get("sliceWarning") .filter(|warning| !warning.is_null()) .and_then(|warning| json_string_field(warning, "reason")); + let slices = if is_canonical_art_spritesheet { + prepare_platform_art_spritesheet_slices(&client, &api_base_url, &api_key, generated).await? + } else { + Vec::new() + }; let warning = platform_art_generation_warning(generated); let resource_id = json_string_field(resource, "resourceId"); let task_id = @@ -1124,6 +1227,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at provider, warning, slice_warning, + slices, generation_route, generation_kind, reference_resource_ids, @@ -1152,6 +1256,23 @@ pub(in crate::agent) fn commit_prepared_platform_art_asset_at( root, prepared, options, + false, + before_replace, + |_| Ok(()), + ) +} + +pub(in crate::agent) fn commit_prepared_platform_art_asset_strict_slices_at( + root: &Path, + prepared: PreparedPlatformArtAssetGeneration, + options: &PlatformArtAssetGenerationOptions, + before_replace: impl FnOnce(&Path) -> Result<(), String>, +) -> Result { + commit_prepared_platform_art_asset_with_before_replace_hook( + root, + prepared, + options, + true, before_replace, |_| Ok(()), ) @@ -1180,10 +1301,237 @@ fn move_platform_art_asset_without_replacing( Ok(()) } +fn write_new_platform_art_slice(path: &Path, bytes: &[u8]) -> Result { + let mut output = fs::OpenOptions::new(); + output.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + output.custom_flags(libc::O_NOFOLLOW); + output.mode(0o600); + } + let mut output = match output.open(path) { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let existing = fs::read(path) + .map_err(|read_error| format!("回读既有平台图集切片失败:{read_error}"))?; + if existing == bytes { + return Ok(false); + } + return Err(format!( + "平台图集切片路径已存在且内容冲突:{}", + path.display() + )); + } + Err(error) => { + return Err(format!("创建平台图集切片失败:{}: {error}", path.display())); + } + }; + output.write_all(bytes).map_err(|error| { + let _ = fs::remove_file(path); + format!("写入平台图集切片失败:{}: {error}", path.display()) + })?; + Ok(true) +} + +fn replace_platform_art_slice_file(path: &Path, bytes: &[u8], suffix: &str) -> Result<(), String> { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("manifest.json"); + let temporary = path.with_file_name(format!(".{file_name}.replacement.{suffix}")); + let backup = path.with_file_name(format!(".{file_name}.previous.{suffix}")); + write_new_platform_art_slice(&temporary, bytes)?; + let had_previous = path.is_file(); + if had_previous { + if let Err(error) = fs::rename(path, &backup) { + let _ = fs::remove_file(&temporary); + return Err(format!("准备替换平台图集切片文件失败:{error}")); + } + } + if let Err(error) = fs::rename(&temporary, path) { + if had_previous { + let _ = fs::rename(&backup, path); + } + let _ = fs::remove_file(&temporary); + return Err(format!("安装平台图集切片文件失败:{error}")); + } + if had_previous { + let _ = fs::remove_file(backup); + } + Ok(()) +} + +struct PlatformArtSliceContractRollback { + snapshots: Vec<(PathBuf, Option>)>, + suffix: String, + armed: bool, +} + +impl PlatformArtSliceContractRollback { + fn capture(root: &Path, suffix: &str) -> Result { + let paths = [ + "assets/art-spritesheet-slices/player.png", + "assets/art-spritesheet-slices/blocks-and-targets.png", + "assets/art-spritesheet-slices/obstacles-and-scene.png", + "assets/art-spritesheet-slices/feedback-effects.png", + "assets/art-spritesheet-slices/manifest.json", + ]; + let snapshots = paths + .into_iter() + .map(|local_path| { + let path = resolve_local_project_path(root, local_path)?; + let bytes = match fs::read(&path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "读取既有平台图集切片合同失败:{}: {error}", + path.display() + )); + } + }; + Ok((path, bytes)) + }) + .collect::, String>>()?; + Ok(Self { + snapshots, + suffix: format!("rollback.{suffix}"), + armed: true, + }) + } + + fn disarm(&mut self) { + self.armed = false; + } + + fn restore(&mut self) { + if !self.armed { + return; + } + for (path, bytes) in &self.snapshots { + match bytes { + Some(bytes) => { + let _ = replace_platform_art_slice_file(path, bytes, &self.suffix); + } + None => { + let _ = fs::remove_file(path); + } + } + } + self.armed = false; + } +} + +impl Drop for PlatformArtSliceContractRollback { + fn drop(&mut self) { + self.restore(); + } +} + +fn commit_prepared_platform_art_slices_at( + root: &Path, + slices: Vec, + generation_key: &str, + source_resource_id: Option<&str>, + suffix: &str, +) -> Result, String> { + if slices.is_empty() { + return Ok(Vec::new()); + } + let directory = format!( + "assets/art-spritesheet-slices/{}", + sanitize_file_name(generation_key) + ); + let directory_path = resolve_local_project_path(root, &directory)?; + fs::create_dir_all(&directory_path).map_err(|error| { + format!( + "创建平台图集切片目录失败:{}: {error}", + directory_path.display() + ) + })?; + let mut generated = Vec::with_capacity(slices.len()); + let mut created_paths = Vec::with_capacity(slices.len()); + let mut slice_paths = Vec::with_capacity(slices.len()); + for (index, slice) in slices.into_iter().enumerate() { + let local_path = format!("{directory}/{:02}.{}", index + 1, slice.extension); + let absolute_path = resolve_local_project_path(root, &local_path)?; + let created = match write_new_platform_art_slice(&absolute_path, &slice.download.bytes) { + Ok(created) => created, + Err(error) => { + for created in &created_paths { + let _ = fs::remove_file(created); + } + return Err(error); + } + }; + if created { + created_paths.push(absolute_path.clone()); + } + slice_paths.push(absolute_path); + generated.push(GeneratedPlatformArtAssetSlice { + name: slice.name, + width: slice.width, + height: slice.height, + local_path, + resource_id: slice.resource_id, + asset_object_id: slice.asset_object_id, + }); + } + let usages = [ + "player", + "blocks-and-targets", + "obstacles-and-scene", + "feedback-effects", + ]; + for (index, usage) in usages.iter().enumerate() { + let Some(slice) = generated.get_mut(index) else { + break; + }; + let canonical_local_path = format!("assets/art-spritesheet-slices/{usage}.png"); + let canonical_path = resolve_local_project_path(root, &canonical_local_path)?; + replace_platform_art_slice_file( + &canonical_path, + &fs::read(&slice_paths[index]).map_err(|error| { + format!( + "回读平台图集切片失败:{}: {error}", + slice_paths[index].display() + ) + })?, + suffix, + )?; + slice.local_path = canonical_local_path; + } + let manifest = serde_json::json!({ + "schemaVersion": "game-art-slices.v1", + "source": "assets/art-spritesheet.png", + "sourceResourceId": source_resource_id, + "slices": generated.iter().enumerate().map(|(index, slice)| serde_json::json!({ + "name": slice.name, + "path": slice.local_path, + "width": slice.width, + "height": slice.height, + "usage": usages.get(index).copied(), + })).collect::>(), + }); + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|error| format!("序列化平台图集切片清单失败:{error}"))?; + let manifest_path = + resolve_local_project_path(root, "assets/art-spritesheet-slices/manifest.json")?; + if let Err(error) = replace_platform_art_slice_file(&manifest_path, &manifest_bytes, suffix) { + for created in &created_paths { + let _ = fs::remove_file(created); + } + return Err(error); + } + Ok(generated) +} + fn commit_prepared_platform_art_asset_with_before_replace_hook( root: &Path, prepared: PreparedPlatformArtAssetGeneration, options: &PlatformArtAssetGenerationOptions, + require_complete_core_slices: bool, before_replace: impl FnOnce(&Path) -> Result<(), String>, after_backup_before_install: impl FnOnce(&Path) -> Result<(), String>, ) -> Result { @@ -1200,7 +1548,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( model, provider, warning, - slice_warning, + mut slice_warning, + slices, generation_route, generation_kind, reference_resource_ids, @@ -1240,6 +1589,23 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( (local_path, absolute_path) } }; + if require_complete_core_slices && slices.len() != 4 { + return Err(format!( + "game-chat 图集必须恰好包含 4 个独立切片,实际为 {} 个", + slices.len() + )); + } + if require_complete_core_slices + && resource_id + .as_deref() + .map(str::trim) + .filter(|resource_id| !resource_id.is_empty()) + .is_none() + { + return Err( + "game-chat 图集必须包含稳定的 Canvas resourceId,已在本地落盘前拒绝提交".to_string(), + ); + } if let Some(parent) = absolute_path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建平台生成素材目录失败:{}: {error}", parent.display()))?; @@ -1253,6 +1619,41 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( .unwrap_or_default() .as_nanos() ); + let mut before_replace = Some(before_replace); + if require_complete_core_slices && options.replace_existing { + let authorize_replace = before_replace + .take() + .expect("strict replacement authorization is available"); + if let Err(error) = authorize_replace(&absolute_path) { + return Err(format!("准备替换平台生成素材失败:{error}")); + } + let final_fingerprint = read_existing_platform_art_asset_fingerprint(root, &local_path); + if final_fingerprint.as_ref().ok() != replacement_fingerprint.as_ref() { + return Err("待替换图片在提交临界点发生变化,已拒绝覆盖".to_string()); + } + } + let mut strict_slice_rollback = if require_complete_core_slices { + Some(PlatformArtSliceContractRollback::capture( + root, + &replacement_suffix, + )?) + } else { + None + }; + let (slices, strict_generated_slices) = if require_complete_core_slices { + ( + Vec::new(), + commit_prepared_platform_art_slices_at( + root, + slices, + file_stem, + resource_id.as_deref(), + &replacement_suffix, + )?, + ) + } else { + (slices, Vec::new()) + }; let output_path = if options.replace_existing { absolute_path.with_file_name(format!( ".{}.replacement.{replacement_suffix}", @@ -1290,14 +1691,16 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( )) }); if let Some(backup_path) = replacement_backup_path.as_ref() { - if let Err(error) = before_replace(&absolute_path) { - let _ = fs::remove_file(&output_path); - return Err(format!("准备替换平台生成素材失败:{error}")); - } - let final_fingerprint = read_existing_platform_art_asset_fingerprint(root, &local_path); - if final_fingerprint.as_ref().ok() != replacement_fingerprint.as_ref() { - let _ = fs::remove_file(&output_path); - return Err("待替换图片在提交临界点发生变化,已拒绝覆盖".to_string()); + if let Some(authorize_replace) = before_replace.take() { + if let Err(error) = authorize_replace(&absolute_path) { + let _ = fs::remove_file(&output_path); + return Err(format!("准备替换平台生成素材失败:{error}")); + } + let final_fingerprint = read_existing_platform_art_asset_fingerprint(root, &local_path); + if final_fingerprint.as_ref().ok() != replacement_fingerprint.as_ref() { + let _ = fs::remove_file(&output_path); + return Err("待替换图片在提交临界点发生变化,已拒绝覆盖".to_string()); + } } if let Err(error) = fs::rename(&absolute_path, backup_path) { let _ = fs::remove_file(&output_path); @@ -1373,6 +1776,30 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( return Err(error); } }; + let generated_slices = if require_complete_core_slices { + strict_generated_slices + } else { + match commit_prepared_platform_art_slices_at( + root, + slices, + file_stem, + resource_id.as_deref(), + &replacement_suffix, + ) { + Ok(slices) => slices, + Err(error) => { + slice_warning = Some( + slice_warning + .map(|warning| format!("{warning};本地切片持久化失败:{error}")) + .unwrap_or_else(|| format!("本地切片持久化失败:{error}")), + ); + Vec::new() + } + } + }; + if let Some(rollback) = strict_slice_rollback.as_mut() { + rollback.disarm(); + } if let Some(backup_path) = replacement_backup_path.as_ref() { let _ = fs::remove_file(backup_path); } @@ -1391,6 +1818,14 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( "assetFolderId": canvas_context.asset_folder_id, "canvasName": canvas_context.canvas_name, "sliceWarning": slice_warning.clone(), + "slices": generated_slices.iter().map(|slice| serde_json::json!({ + "name": slice.name, + "localPath": slice.local_path, + "width": slice.width, + "height": slice.height, + "resourceId": slice.resource_id, + "assetObjectId": slice.asset_object_id, + })).collect::>(), "generationRoute": generation_route, "generationKind": generation_kind, "referenceResourceIds": reference_resource_ids, @@ -1398,6 +1833,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( )?; Ok(GeneratedPlatformArtAsset { asset: registered, + slices: generated_slices, resource_id, asset_object_id, task_id, @@ -1537,6 +1973,56 @@ mod canvas_generation_tests { } } + #[tokio::test] + async fn prepares_all_external_icon_image_slices_for_local_persistence() { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind icon slice download fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let png = rgba_test_png(120).bytes; + let server = std::thread::spawn(move || { + for _ in 0..4 { + let (mut stream, _) = listener.accept().expect("accept slice download"); + let _ = read_test_http_request(&mut stream); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ) + .expect("write slice response headers"); + stream.write_all(&png).expect("write slice response body"); + } + }); + let generated = serde_json::json!({ + "iconImageSrcs": (0..4).map(|index| serde_json::json!({ + "name": format!("素材 {}", index + 1), + "imageSrc": format!("{base_url}/slice-{index}.png"), + "width": 1, + "height": 1, + "resource": { + "resourceId": format!("slice-resource-{index}"), + "assetObjectId": format!("slice-object-{index}"), + "imageSrc": format!("{base_url}/slice-{index}.png"), + } + })).collect::>() + }); + + let slices = prepare_platform_art_spritesheet_slices( + &reqwest::Client::new(), + &base_url, + "test-api-key", + &generated, + ) + .await + .expect("prepare external icon slices"); + server.join().expect("join slice download fixture"); + + assert_eq!(slices.len(), 4); + assert_eq!(slices[0].name, "素材 1"); + assert_eq!(slices[0].resource_id.as_deref(), Some("slice-resource-0")); + assert_eq!(slices[3].asset_object_id.as_deref(), Some("slice-object-3")); + assert!(slices.iter().all(|slice| slice.extension == "png")); + } + #[tokio::test] async fn generation_submit_response_loss_is_not_retried() { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind retry fixture"); @@ -2495,6 +2981,7 @@ mod canvas_generation_tests { provider: Some("test-provider".to_string()), warning: None, slice_warning: None, + slices: Vec::new(), generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], @@ -2502,6 +2989,269 @@ mod canvas_generation_tests { } } + fn prepared_replacement_with_core_slices( + root: &Path, + bytes: &[u8], + ) -> PreparedPlatformArtAssetGeneration { + let mut prepared = prepared_replacement(root, bytes); + prepared.slices = ["玩家主体", "目标物", "场景障碍", "反馈特效"] + .into_iter() + .enumerate() + .map(|(index, name)| PreparedPlatformArtAssetSlice { + name: name.to_string(), + width: 1, + height: 1, + download: rgba_test_png(100 + index as u8), + resource_id: Some(format!("replacement-slice-resource-{index}")), + asset_object_id: Some(format!("replacement-slice-object-{index}")), + extension: "png".to_string(), + }) + .collect(); + prepared + } + + fn existing_core_slice_contract(root: &Path) -> Vec<(PathBuf, Vec)> { + let directory = root.join("assets/art-spritesheet-slices"); + fs::create_dir_all(&directory).expect("create existing slice contract directory"); + let mut snapshots = [ + "player.png", + "blocks-and-targets.png", + "obstacles-and-scene.png", + "feedback-effects.png", + ] + .into_iter() + .enumerate() + .map(|(index, name)| { + let path = directory.join(name); + let bytes = format!("old-slice-{index}").into_bytes(); + fs::write(&path, &bytes).expect("write existing canonical slice"); + (path, bytes) + }) + .collect::>(); + let manifest_path = directory.join("manifest.json"); + let manifest_bytes = b"old-slice-manifest".to_vec(); + fs::write(&manifest_path, &manifest_bytes).expect("write existing slice manifest"); + snapshots.push((manifest_path, manifest_bytes)); + snapshots + } + + fn assert_core_slice_contract_unchanged(snapshots: &[(PathBuf, Vec)]) { + for (path, expected) in snapshots { + assert_eq!( + fs::read(path).expect("read preserved slice contract file"), + *expected, + "{} changed before the spritesheet replacement was authorized", + path.display() + ); + } + } + + #[test] + fn strict_slice_commit_rejects_non_four_slice_results_before_replacing_the_sheet() { + let temporary = tempfile::tempdir().expect("create strict slice project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-slices", "严格切片测试") + .expect("init strict slice project"); + let path = root.join("assets/art-spritesheet.png"); + fs::write(&path, b"old-image").expect("write old spritesheet"); + let prepared = prepared_replacement(root, b"new-image"); + + let error = commit_prepared_platform_art_asset_strict_slices_at( + root, + prepared, + &replacement_options(), + |_| Ok(()), + ) + .expect_err("strict game-chat commit must require exactly four slices"); + + assert!(error.contains("恰好包含 4 个独立切片")); + assert_eq!(fs::read(path).expect("read preserved sheet"), b"old-image"); + assert!(!root + .join("assets/art-spritesheet-slices/manifest.json") + .exists()); + } + + #[test] + fn strict_slice_commit_rejects_missing_resource_id_before_writing_any_artifact() { + let temporary = tempfile::tempdir().expect("create strict resource project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-resource", "严格资源测试") + .expect("init strict resource project"); + let sheet_path = root.join("assets/art-spritesheet.png"); + fs::write(&sheet_path, b"temporary-old-image").expect("write setup sheet"); + let mut prepared = prepared_replacement_with_core_slices(root, b"new-image"); + fs::remove_file(&sheet_path).expect("remove setup sheet"); + prepared.replacement_fingerprint = None; + prepared.resource_id = None; + let mut options = replacement_options(); + options.replace_existing = false; + + let error = + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + Ok(()) + }) + .expect_err("strict game-chat commit must require a stable resource id"); + + assert!(error.contains("resourceId")); + assert!(!sheet_path.exists()); + assert!(!root.join("assets/art-spritesheet-slices").exists()); + } + + #[test] + fn strict_slice_replacement_preserves_existing_contract_when_authorization_fails() { + let temporary = tempfile::tempdir().expect("create strict authorization project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-authorization", "严格授权测试") + .expect("init strict authorization project"); + let sheet_path = root.join("assets/art-spritesheet.png"); + fs::write(&sheet_path, b"old-image").expect("write old spritesheet"); + let prepared = prepared_replacement_with_core_slices(root, b"new-image"); + let snapshots = existing_core_slice_contract(root); + + let error = commit_prepared_platform_art_asset_strict_slices_at( + root, + prepared, + &replacement_options(), + |_| Err("replacement authorization expired".to_string()), + ) + .expect_err("strict replacement must stop before changing canonical slices"); + + assert!(error.contains("replacement authorization expired")); + assert_eq!(fs::read(sheet_path).expect("read old sheet"), b"old-image"); + assert_core_slice_contract_unchanged(&snapshots); + } + + #[test] + fn strict_slice_replacement_preserves_existing_contract_when_sheet_fingerprint_drifts() { + let temporary = tempfile::tempdir().expect("create strict fingerprint project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-fingerprint", "严格指纹测试") + .expect("init strict fingerprint project"); + let sheet_path = root.join("assets/art-spritesheet.png"); + fs::write(&sheet_path, b"old-image").expect("write old spritesheet"); + let prepared = prepared_replacement_with_core_slices(root, b"new-image"); + let snapshots = existing_core_slice_contract(root); + fs::write(&sheet_path, b"concurrent-image").expect("simulate concurrent replacement"); + + let error = commit_prepared_platform_art_asset_strict_slices_at( + root, + prepared, + &replacement_options(), + |_| Ok(()), + ) + .expect_err("strict replacement must reject the stale original fingerprint"); + + assert!(error.contains("发生变化")); + assert_eq!( + fs::read(sheet_path).expect("read concurrent sheet"), + b"concurrent-image" + ); + assert_core_slice_contract_unchanged(&snapshots); + } + + #[test] + fn strict_slice_replacement_rolls_back_slice_contract_when_sheet_install_fails() { + let temporary = tempfile::tempdir().expect("create strict atomic project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-atomic", "严格原子提交测试") + .expect("init strict atomic project"); + let sheet_path = root.join("assets/art-spritesheet.png"); + fs::write(&sheet_path, b"old-image").expect("write old spritesheet"); + let prepared = prepared_replacement_with_core_slices(root, b"new-image"); + let snapshots = existing_core_slice_contract(root); + + let error = commit_prepared_platform_art_asset_with_before_replace_hook( + root, + prepared, + &replacement_options(), + true, + |_| Ok(()), + |_| Err("simulated sheet install failure".to_string()), + ) + .expect_err("strict replacement must roll back slices when sheet install fails"); + + assert!(error.contains("simulated sheet install failure")); + assert_eq!( + fs::read(sheet_path).expect("read restored sheet"), + b"old-image" + ); + assert_core_slice_contract_unchanged(&snapshots); + } + + #[test] + fn committed_art_spritesheet_persists_server_slices_and_usage_manifest() { + let temporary = tempfile::tempdir().expect("create sliced spritesheet project"); + let root = temporary.path(); + init_local_game_project_at(root, "canvas-slices", "图集切片测试") + .expect("init sliced spritesheet project"); + let main_download = rgba_test_png(0); + let slices = ["玩家主体", "目标物", "场景障碍", "反馈特效"] + .into_iter() + .enumerate() + .map(|(index, name)| PreparedPlatformArtAssetSlice { + name: name.to_string(), + width: 1, + height: 1, + download: rgba_test_png(100 + index as u8), + resource_id: Some(format!("slice-resource-{index}")), + asset_object_id: Some(format!("slice-object-{index}")), + extension: "png".to_string(), + }) + .collect::>(); + let prepared = PreparedPlatformArtAssetGeneration { + requested_output_path: Some("assets/art-spritesheet.png".to_string()), + replacement_fingerprint: None, + download: main_download, + canvas_context: ExternalCanvasGenerationContext { + project_id: "canvas-project".to_string(), + asset_folder_id: "asset-folder".to_string(), + canvas_name: "canvas-slices".to_string(), + }, + resource_id: Some("spritesheet-resource".to_string()), + task_id: Some("spritesheet-task".to_string()), + asset_object_id: Some("spritesheet-object".to_string()), + canvas_project_id: Some("canvas-project".to_string()), + generated_prompt: Some("四类原创素材".to_string()), + model: Some("test-image-model".to_string()), + provider: Some("test-provider".to_string()), + warning: None, + slice_warning: None, + slices, + generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + generation_kind: "icon-spritesheet".to_string(), + reference_resource_ids: vec!["art-spec-resource".to_string()], + extension: "png".to_string(), + }; + let mut options = replacement_options(); + options.replace_existing = false; + + let generated = + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + Ok(()) + }) + .expect("commit sliced spritesheet"); + + assert_eq!(generated.slices.len(), 4); + for slice in &generated.slices { + assert!( + root.join(&slice.local_path).is_file(), + "{}", + slice.local_path + ); + } + let manifest: serde_json::Value = serde_json::from_slice( + &fs::read(root.join("assets/art-spritesheet-slices/manifest.json")) + .expect("read slice manifest"), + ) + .expect("parse slice manifest"); + assert_eq!(manifest["schemaVersion"], "game-art-slices.v1"); + assert_eq!(manifest["source"], "assets/art-spritesheet.png"); + assert_eq!(manifest["sourceResourceId"], "spritesheet-resource"); + assert_eq!(manifest["slices"].as_array().map(Vec::len), Some(4)); + assert_eq!(manifest["slices"][0]["usage"], "player"); + assert_eq!(manifest["slices"][3]["usage"], "feedback-effects"); + } + #[test] fn committed_canvas_replacement_updates_the_fixed_path_without_delete_first() { let temporary = tempfile::tempdir().expect("create replacement project"); @@ -2606,6 +3356,7 @@ mod canvas_generation_tests { root, prepared, &replacement_options(), + false, |_| Ok(()), |output_path| { fs::write(output_path, b"concurrent-image") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index cba6238dc..9d2ec9aef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -1,6 +1,6 @@ use super::*; -const RUNTIME_PROMPT_BUNDLE_VERSION: &str = "2026-08-03.3"; +const RUNTIME_PROMPT_BUNDLE_VERSION: &str = "2026-08-03.4"; const RUNTIME_PROMPT_BUNDLE_MANIFEST: &str = include_str!("../../prompts/runtime/manifest.json"); const RUNTIME_PROMPT_COMMON: &str = include_str!("../../prompts/runtime/common.md"); const RUNTIME_PROMPT_ISOLATED_AGENT_CONTRACT: &str = @@ -10,6 +10,9 @@ const RUNTIME_PROMPT_PLATFORM_DEFAULT: &str = include_str!("../../prompts/runtime/platform/default.md"); #[cfg(any(test, target_os = "linux"))] const RUNTIME_PROMPT_PLATFORM_LINUX: &str = include_str!("../../prompts/runtime/platform/linux.md"); +#[cfg(test)] +const RUNTIME_PROMPT_CODE_PROTOTYPE_GAME_CHAT: &str = + include_str!("../../prompts/runtime/roles/code-prototype-game-chat.md"); #[cfg(not(target_os = "linux"))] const RUNTIME_PROMPT_PLATFORM: &str = RUNTIME_PROMPT_PLATFORM_DEFAULT; #[cfg(target_os = "linux")] @@ -654,6 +657,9 @@ mod tests { "isolatedAgentContract": "isolated-agent-contract.md", "platformDefault": "platform/default.md", "platformLinux": "platform/linux.md", + "roles": { + "codePrototypeGameChat": "roles/code-prototype-game-chat.md" + }, "supervisor": { "intro": "supervisor/intro.md", "visualContractWithoutEditor": "supervisor/visual-contract-without-editor.md", @@ -669,6 +675,7 @@ mod tests { RUNTIME_PROMPT_ISOLATED_AGENT_CONTRACT, RUNTIME_PROMPT_PLATFORM_DEFAULT, RUNTIME_PROMPT_PLATFORM_LINUX, + RUNTIME_PROMPT_CODE_PROTOTYPE_GAME_CHAT, SUPERVISOR_INTRO, SUPERVISOR_VISUAL_CONTRACT_WITHOUT_EDITOR, SUPERVISOR_VISUAL_CONTRACT_WITH_EDITOR, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 77af35355..441981ab9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -3,7 +3,8 @@ use crate::mcp::GAME_CREATOR_MCP_CALL_TOOL; const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止调用 respond_to_user;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。"; -const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。必须先保护既有游戏语义和已完成产物:如果已有 observation 尚未包含 game/index.html 的当前正文与摘要,本响应第一步必须调用 file.read(path=game/index.html),不得猜测入口仍是初始化占位。只有文件缺失或正文与初始化页面一致、且当前 run 尚未写入项目时,才允许用一次 file.write 生成完整、自包含、可运行的 game/index.html;检测到非占位入口、已有可玩实现、当前 revision 已验证,或当前 run 已发生修改时,禁止整文件 file.write,必须保留原玩法,优先执行静态检查/试玩,确需修复时只能依据已读取的精确原文做最小 file.patch。不得把“继续”、continue 或其它纯续跑词当作游戏主题;缺少可继承的具体目标时必须失败关闭,不得另造收集能量等新玩法。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局。../assets/art-spec.png 是视觉规范图,只能用于理解风格、构图与色板,禁止把整张规范图铺成游戏背景,禁止从中裁切或绘制玩家、目标和场景实体,也不能把它当作运行时必需图片;只有存在已登记的独立派生 spritesheet 时才可加载该派生素材,否则使用与规范一致的自包含原创 Canvas 视觉,并保证规范图加载失败不影响游戏启动。首次实现或最小修复后不要继续扩写功能;Runtime 会自动执行静态自检并在通过后立即试玩。"; +const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = + include_str!("../../../prompts/runtime/roles/code-prototype-game-chat.md"); fn game_chat_fast_path_prompt_for_root_source( agent_id: &str, @@ -741,14 +742,14 @@ mod tests { } #[test] - fn game_chat_fast_path_prompt_protects_existing_game_and_treats_art_spec_as_reference() { + fn game_chat_fast_path_prompt_protects_existing_game_and_requires_cropped_spritesheet_use() { let prompt = game_chat_fast_path_prompt_for_root_source( "code-prototype", AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, ) .expect("game-chat code fast path prompt"); - assert!(prompt.contains("五分钟快车道")); + assert!(prompt.contains("素材完整快车道")); assert!(prompt.contains("第一步必须调用 file.read(path=game/index.html)")); assert!(prompt.contains("正文与初始化页面一致")); assert!(prompt.contains("当前 run 尚未写入项目")); @@ -756,13 +757,14 @@ mod tests { assert!(prompt.contains("禁止整文件 file.write")); assert!(prompt.contains("最小 file.patch")); assert!(prompt.contains("不得把“继续”")); - assert!(prompt.contains("../assets/art-spec.png")); - assert!(prompt.contains("只能用于理解风格、构图与色板")); - assert!(prompt.contains("禁止把整张规范图铺成游戏背景")); - assert!(prompt.contains("禁止从中裁切或绘制玩家、目标和场景实体")); - assert!(prompt.contains("自包含原创 Canvas 视觉")); - assert!(prompt.contains("规范图加载失败不影响游戏启动")); - assert!(prompt.contains("独立派生 spritesheet")); + assert!(prompt.contains("assets/art-spec.png")); + assert!(prompt.contains("不得在运行时加载")); + assert!(prompt.contains("assets/art-spritesheet.png")); + assert!(prompt.contains("assets/art-spritesheet-slices/manifest.json")); + assert!(prompt.contains("四个不同的独立透明素材")); + assert!(prompt.contains("不得猜测整张图集是等分网格")); + assert!(prompt.contains("不得以纯代码几何替代核心实体")); + assert!(prompt.contains("四类切片或其可见使用任一缺失时不得交付")); assert!(game_chat_fast_path_prompt_for_root_source( "quality-review", AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs index 5275693a4..00aa1f684 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -1,13 +1,13 @@ -//! A deterministic, dependency-free game-chat fallback. +//! A deterministic game-chat fallback that consumes the generated local art slices. //! //! This module deliberately does not start the runtime or write project files. It only -//! renders a small, self-contained HTML document that the runtime can use when it needs to +//! renders a small HTML document that the runtime can use when it needs to //! make a first playable version available before the normal generation pass finishes. use super::*; -pub(crate) const GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS: u64 = 240; -pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS: u64 = 300; +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS: u64 = 4_200; +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS: u64 = 4_500; pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX: &str = "game-chat-first-playable-hard-budget-exhausted"; @@ -76,12 +76,12 @@ pub(crate) fn game_chat_fast_path_provider_timeout( fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRuntimeToolPlan { AgentRuntimeToolPlan { - thinking_summary: "game-chat 首版快车道正在按固定最短路径推进。".to_string(), + thinking_summary: "game-chat 素材完整快车道正在按固定路径推进。".to_string(), plan_update: None, plan: Vec::new(), actions: vec![AgentRuntimeToolAction { tool: tool.to_string(), - reason: Some("在五分钟预算内形成并验证首个可玩版本".to_string()), + reason: Some("在素材完整预算内生成、接入并验证首个可玩版本".to_string()), input, }], response: String::new(), @@ -94,6 +94,7 @@ fn game_chat_fast_path_fallback_write_plan_for_root( ) -> Result { ensure_game_chat_fallback_theme_is_specific(task)?; ensure_game_chat_fallback_targets_initial_placeholder(root)?; + ensure_game_chat_fallback_gameplay_is_supported(task)?; Ok(game_chat_fast_path_action( "file.write", serde_json::json!({ @@ -103,20 +104,42 @@ fn game_chat_fast_path_fallback_write_plan_for_root( )) } -fn ensure_game_chat_fallback_theme_is_specific(task: &str) -> Result<(), String> { - let normalized = task - .trim() - .trim_matches(|character: char| { - character.is_whitespace() - || matches!(character, '。' | ',' | ',' | '.' | '!' | '!' | '?' | '?') - }) - .to_ascii_lowercase(); - if normalized.is_empty() - || matches!( - normalized.as_str(), - "继续" | "继续吧" | "继续做" | "接着" | "接着做" | "continue" | "go on" - ) +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GameChatFallbackGameplay { + Collection, + Tetris, +} + +fn game_chat_fallback_gameplay(task: &str) -> Option { + let normalized = task.trim().to_ascii_lowercase(); + if ["俄罗斯方块", "方块下落", "tetromino", "tetris"] + .iter() + .any(|keyword| normalized.contains(keyword)) { + return Some(GameChatFallbackGameplay::Tetris); + } + if ["收集", "能量", "采集"] + .iter() + .any(|keyword| normalized.contains(keyword)) + { + return Some(GameChatFallbackGameplay::Collection); + } + None +} + +fn ensure_game_chat_fallback_gameplay_is_supported(task: &str) -> Result<(), String> { + if game_chat_fallback_gameplay(task).is_some() { + Ok(()) + } else { + Err( + "game-chat 确定性 fallback 没有当前玩法的真实语义模板,拒绝生成名称不同但玩法固定的收集游戏;应由 code-prototype 继续实现原任务" + .to_string(), + ) + } +} + +fn ensure_game_chat_fallback_theme_is_specific(task: &str) -> Result<(), String> { + if task.trim().is_empty() || is_pure_autonomous_continuation_intent(task) { return Err( "game-chat 首版缺少可继承的具体游戏目标,拒绝把纯续跑指令当作新游戏主题".to_string(), ); @@ -206,6 +229,16 @@ fn game_chat_fast_path_canvas_asset_plan(task_id: &str, root_task: &str) -> Agen "icon-spec", "游戏统一视觉规范图", ), + "art-asset-plan" => ( + format!( + "为原创小游戏“{theme}”从已登记的统一视觉规范图派生首版透明核心素材图集:严格只生成四个彼此分离的完整主体,并按左上玩家主体、右上方块/目标/危险物、左下障碍/场景、右下得分/受击/胜负反馈的阅读顺序排列;每类只占一个连通主体,附属部件必须贴合主体,边界清晰、透明背景,适合拆成四张独立素材,不得生成额外装饰、完整游戏截图、海报、UI 面板或不透明背景。" + ), + "assets/art-spritesheet.png", + "1:1", + "1K", + "art-spritesheet", + "游戏首版核心美术素材", + ), _ => unreachable!("only deterministic game-chat art tasks use this helper"), }; game_chat_fast_path_action( @@ -222,34 +255,129 @@ fn game_chat_fast_path_canvas_asset_plan(task_id: &str, root_task: &str) -> Agen ) } +pub(in crate::agent) fn game_chat_fast_path_art_manifest_content() -> String { + serde_json::json!({ + "schemaVersion": "game-art-manifest.v1", + "status": "generated", + "assets": [{ + "path": "assets/art-spritesheet.png", + "kind": "art-spritesheet", + "usage": ["player", "blocks-and-targets", "obstacles-and-scene", "feedback-effects"] + }], + "sliceManifest": "assets/art-spritesheet-slices/manifest.json", + "requiredSliceUsages": ["player", "blocks-and-targets", "obstacles-and-scene", "feedback-effects"] + }) + .to_string() +} + +fn game_chat_fast_path_has_art_manifest(root: &Path) -> bool { + read_local_project_file_at(root, "assets/manifest.art.json") + .ok() + .and_then(|file| serde_json::from_str::(&file.content).ok()) + .is_some_and(|manifest| { + manifest.get("status").and_then(serde_json::Value::as_str) == Some("generated") + && manifest + .get("assets") + .and_then(serde_json::Value::as_array) + .is_some_and(|assets| { + assets.iter().any(|asset| { + asset.get("path").and_then(serde_json::Value::as_str) + == Some("assets/art-spritesheet.png") + }) + }) + && manifest + .get("sliceManifest") + .and_then(serde_json::Value::as_str) + == Some("assets/art-spritesheet-slices/manifest.json") + && game_chat_fast_path_art_slice_paths(root).is_ok() + }) +} + +pub(in crate::agent) fn game_chat_fast_path_art_slice_paths( + root: &Path, +) -> Result, String> { + let file = read_local_project_file_at(root, "assets/art-spritesheet-slices/manifest.json")?; + let manifest: serde_json::Value = serde_json::from_str(&file.content) + .map_err(|error| format!("game-chat 图集切片清单不是有效 JSON:{error}"))?; + if manifest + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + != Some("game-art-slices.v1") + || manifest.get("source").and_then(serde_json::Value::as_str) + != Some("assets/art-spritesheet.png") + { + return Err("game-chat 图集切片清单 schema 或 source 无效".to_string()); + } + let project_manifest = read_manifest_for_project(root)?; + let current_resource_id = project_manifest + .assets + .iter() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .and_then(|asset| asset.source.resource_id.as_deref()) + .ok_or_else(|| "game-chat 当前图集缺少 Canvas resourceId".to_string())?; + if manifest + .get("sourceResourceId") + .and_then(serde_json::Value::as_str) + != Some(current_resource_id) + { + return Err("game-chat 图集切片清单不属于当前 art-spritesheet resource".to_string()); + } + let slices = manifest + .get("slices") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "game-chat 图集切片清单缺少 slices".to_string())?; + let required_usages = [ + "player", + "blocks-and-targets", + "obstacles-and-scene", + "feedback-effects", + ]; + let mut paths = Vec::with_capacity(required_usages.len()); + for usage in required_usages { + let slice = slices + .iter() + .find(|slice| slice.get("usage").and_then(serde_json::Value::as_str) == Some(usage)) + .ok_or_else(|| format!("game-chat 图集切片清单缺少 {usage} 素材"))?; + let expected_path = format!("assets/art-spritesheet-slices/{usage}.png"); + let path = slice + .get("path") + .and_then(serde_json::Value::as_str) + .filter(|path| *path == expected_path) + .ok_or_else(|| format!("game-chat {usage} 切片路径无效"))?; + let absolute = resolve_local_project_path(root, path)?; + let bytes = fs::read(&absolute) + .map_err(|error| format!("game-chat {usage} 切片无法读取:{error}"))?; + image::load_from_memory(&bytes) + .map_err(|error| format!("game-chat {usage} 切片无法解码:{error}"))?; + paths.push(path.to_string()); + } + Ok(paths) +} + pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( root: &Path, budget: &GameChatFastPathBudget, _fallback_task: &str, ) -> Result { let root_task = game_chat_fast_path_root_task(root, budget)?; + if !game_chat_fast_path_has_visual_asset(root, "art-asset-plan") { + return Err( + "game-chat 首版缺少已登记的 assets/art-spritesheet.png,拒绝生成纯代码核心画面" + .to_string(), + ); + } + game_chat_fast_path_art_slice_paths(root)?; let contract = read_autonomous_completion_contract(root, &budget.root_agent_id, &budget.root_run_id)? .ok_or_else(|| "game-chat 首版快车道缺少 root 完成合同".to_string())?; - let default_index_sha256 = format!("{:x}", Sha256::digest(DEFAULT_GAME_INDEX_HTML.as_bytes())); - if contract - .baseline_index_sha256 - .as_deref() - .is_some_and(|sha256| sha256 != default_index_sha256) - { - return Ok(game_chat_fast_path_action( - "command.run_limited", - serde_json::json!({ "commandId": "game.static_smoke" }), - )); - } let code_run_id = autonomous_manifest_ready_task_run_id(&budget.root_run_id, "code-prototype"); let code_gate = read_game_creator_agent_runtime_verification_gate(root, "code-prototype", &code_run_id)?; - if !game_chat_fallback_targets_initial_placeholder(root)? { - return Ok(game_chat_fast_path_action( - "command.run_limited", - serde_json::json!({ "commandId": "game.static_smoke" }), - )); + if game_chat_fast_path_existing_game_requires_code_mutation(root, &contract)? { + return Err( + "game-chat 检测到既有非占位游戏;code-prototype 必须先读取并实际 patch 现有玩法,取得本人 mutationRevision 后再运行 game.static_smoke,拒绝用只读 smoke 冒充续作" + .to_string(), + ); } if code_gate.mutation_revision.is_some() { return Err( @@ -269,6 +397,18 @@ pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( game_chat_fast_path_fallback_write_plan_for_root(root, &root_task) } +fn game_chat_fast_path_existing_game_requires_code_mutation( + root: &Path, + contract: &AgentRuntimeAutonomousCompletionContract, +) -> Result { + let default_index_sha256 = format!("{:x}", Sha256::digest(DEFAULT_GAME_INDEX_HTML.as_bytes())); + Ok(contract + .baseline_index_sha256 + .as_deref() + .is_some_and(|sha256| sha256 != default_index_sha256) + || !game_chat_fallback_targets_initial_placeholder(root)?) +} + fn game_chat_fast_path_verified_delivery_plan( runtime: &AgentRuntimeState, response: &str, @@ -351,6 +491,42 @@ pub(crate) fn game_chat_fast_path_plan_at( &root_task, ))) } + "art-asset-plan" => { + if game_chat_fast_path_has_visual_asset(root, "art-asset-plan") { + if game_chat_fast_path_has_art_manifest(root) { + return Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "透明核心美术图集已生成、登记并形成资产清单。", + ))); + } + return Err( + "game-chat 已有透明图集但缺少 assets/manifest.art.json,必须走显式 repair 补齐同一 owner 合同" + .to_string(), + ); + } + if !editor_api_key_is_configured() { + return Err( + "game-chat 首版必须配置 External Editor API Key 才能生成透明核心美术图集" + .to_string(), + ); + } + if !game_chat_fast_path_has_visual_asset(root, "art-director") { + return Err( + "game-chat 透明图集生成前缺少已登记的 assets/art-spec.png,拒绝绕过规范图派生" + .to_string(), + ); + } + if game_chat_fast_path_canvas_generation_failed(runtime) { + return Err( + "game-chat 透明核心美术图集生成失败,拒绝退回纯代码核心画面".to_string() + ); + } + let root_task = game_chat_fast_path_root_task(root, &budget)?; + Ok(Some(game_chat_fast_path_canvas_asset_plan( + "art-asset-plan", + &root_task, + ))) + } "preview-readiness" => { if game_chat_fast_path_current_revision_is_verified(root, runtime)? { Ok(Some(game_chat_fast_path_verified_delivery_plan( @@ -414,6 +590,17 @@ pub(crate) fn game_chat_fast_path_plan_at( if current_revision_failed { return Ok(None); } + let contract = read_autonomous_completion_contract( + root, + &budget.root_agent_id, + &budget.root_run_id, + )? + .ok_or_else(|| "game-chat 首版快车道缺少 root 完成合同".to_string())?; + if !owns_current_mutation + && game_chat_fast_path_existing_game_requires_code_mutation(root, &contract)? + { + return Ok(None); + } if runtime.loop_iteration > 1 || budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS { @@ -433,7 +620,11 @@ pub(crate) fn game_chat_fast_path_plan_at( /// attribute and text nodes; it is never interpolated into JavaScript source. pub(crate) fn render_game_chat_fast_path_html(prompt: &str) -> String { let theme = html_escape(&safe_theme_summary(prompt)); - FALLBACK_GAME_HTML.replace(FALLBACK_THEME_MARKER, &theme) + let template = match game_chat_fallback_gameplay(prompt) { + Some(GameChatFallbackGameplay::Tetris) => FALLBACK_TETRIS_GAME_HTML, + _ => FALLBACK_GAME_HTML, + }; + template.replace(FALLBACK_THEME_MARKER, &theme) } fn safe_theme_summary(prompt: &str) -> String { @@ -541,10 +732,41 @@ const FALLBACK_GAME_HTML: &str = r###" const objectiveLabel = document.getElementById('objective-label'); const theme = root.dataset.theme || '轻量互动挑战'; const state = { schemaVersion: 'playable-web-game-state.v1', sequence: 0, phase: 'ready', level: 1, score: 0 }; + const playerArt = new Image(); + const targetArt = new Image(); + const sceneArt = new Image(); + const feedbackArt = new Image(); + const artSlices = [playerArt, targetArt, sceneArt, feedbackArt]; + let artReady = false; + let loadedArtSlices = 0; let playerX = 180; let targetX = 690; let pulse = 0; + function failArtLoad() { + artReady = false; + state.phase = 'lost'; + stateLabel.textContent = '美术素材加载失败'; + phaseLabel.textContent = '素材失败'; + objectiveLabel.textContent = '请修复 assets/art-spritesheet-slices/manifest.json 后重试'; + } + + function markArtLoaded() { + loadedArtSlices += 1; + if (loadedArtSlices === artSlices.length) { + artReady = true; + objectiveLabel.textContent = '四类核心美术素材已就绪,点击开始'; + } + } + for (const image of artSlices) { + image.addEventListener('load', markArtLoaded, { once: true }); + image.addEventListener('error', failArtLoad, { once: true }); + } + playerArt.src = '../assets/art-spritesheet-slices/player.png'; + targetArt.src = '../assets/art-spritesheet-slices/blocks-and-targets.png'; + sceneArt.src = '../assets/art-spritesheet-slices/obstacles-and-scene.png'; + feedbackArt.src = '../assets/art-spritesheet-slices/feedback-effects.png'; + function publish() { state.sequence += 1; stateNode.textContent = JSON.stringify(state); @@ -609,30 +831,19 @@ const FALLBACK_GAME_HTML: &str = r###" context.strokeStyle = '#2a5f85'; context.lineWidth = 3; context.strokeRect(20, 20, canvas.width - 40, canvas.height - 132); - context.save(); - context.translate(playerX, canvas.height - 142); - context.fillStyle = '#6ff3d1'; - context.beginPath(); - context.moveTo(48, 0); - context.lineTo(-34, -30); - context.lineTo(-18, 0); - context.lineTo(-34, 30); - context.closePath(); - context.fill(); - context.fillStyle = '#d8fff5'; - context.beginPath(); - context.arc(4, 0, 11, 0, Math.PI * 2); - context.fill(); - context.restore(); + if (artReady) { + for (let tile = 0; tile < 8; tile += 1) { + context.drawImage(sceneArt, tile * 120 + 12, canvas.height - 106, 96, 80); + } + context.drawImage(playerArt, playerX - 56, canvas.height - 198, 112, 112); + } const targetY = 192 + Math.sin(pulse) * 5; - const targetGradient = context.createRadialGradient(targetX, targetY, 4, targetX, targetY, 48); - targetGradient.addColorStop(0, '#fff5a8'); - targetGradient.addColorStop(.45, '#ffba5a'); - targetGradient.addColorStop(1, '#ff6a4d22'); - context.fillStyle = targetGradient; - context.beginPath(); - context.arc(targetX, targetY, 48, 0, Math.PI * 2); - context.fill(); + if (artReady) { + context.drawImage(targetArt, targetX - 56, targetY - 56, 112, 112); + if (state.score > 0) { + context.drawImage(feedbackArt, playerX + 32, canvas.height - 222, 72, 72); + } + } context.fillStyle = '#eaf4ff'; context.font = 'bold 22px system-ui, sans-serif'; context.fillText(state.phase === 'playing' ? '继续前进' : state.phase === 'won' ? '挑战成功' : state.phase === 'lost' ? '挑战失败' : '点击开始', 38, 58); @@ -648,6 +859,278 @@ const FALLBACK_GAME_HTML: &str = r###" "###; +const FALLBACK_TETRIS_GAME_HTML: &str = r###" + + + + + Genarrative · __GAME_CHAT_THEME__ + + + +
+
+
+

水晶方块挑战 · __GAME_CHAT_THEME__

+

移动、旋转并下落方块,填满横行即可消除;堆到顶部则失败。

+
+
得分 0消行 0准备就绪
+
+
+ +
点击开始,方向键移动,上键或按钮旋转准备就绪
+ +
+ +
+ + + +"###; + #[cfg(test)] mod tests { use super::*; @@ -675,6 +1158,14 @@ mod tests { let contract = read_autonomous_completion_contract(root, &record.agent_id, &record.run_id) .expect("read game-chat completion contract") .expect("game-chat completion contract exists"); + register_fast_path_visual_fixture(root, "assets/art-spec.png", "icon-spec", Vec::new()); + register_fast_path_visual_fixture( + root, + "assets/art-spritesheet.png", + "art-spritesheet", + vec!["fast-path-icon-spec-resource".to_string()], + ); + write_fast_path_art_slice_fixture(root); GameChatFastPathBudget { root_agent_id: record.agent_id, root_run_id: record.run_id, @@ -683,10 +1174,103 @@ mod tests { } } + fn register_fast_path_visual_fixture( + root: &Path, + local_path: &str, + kind: &str, + reference_resource_ids: Vec, + ) { + let alpha = if kind == "art-spritesheet" { + 0 + } else { + u8::MAX + }; + image::RgbaImage::from_pixel(64, 64, image::Rgba([80, 140, 220, alpha])) + .save(root.join(local_path)) + .expect("write fast path visual fixture"); + register_local_asset_at( + root, + local_path, + kind, + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("fast-path-canvas".to_string()), + resource_id: Some(format!("fast-path-{kind}-resource")), + asset_object_id: Some(format!("fast-path-{kind}-object")), + task_id: Some(format!("fast-path-{kind}-task")), + prompt: None, + model: None, + generation_route: Some( + if kind == "art-spritesheet" { + "/api/external/v1/editor/icon-spritesheets/generations" + } else { + "/api/external/v1/editor/images/generations" + } + .to_string(), + ), + generation_kind: Some( + if kind == "art-spritesheet" { + "icon-spritesheet" + } else { + "spec" + } + .to_string(), + ), + reference_resource_ids, + }, + ) + .expect("register fast path visual fixture"); + } + + fn write_fast_path_art_slice_fixture(root: &Path) { + let directory = root.join("assets/art-spritesheet-slices"); + fs::create_dir_all(&directory).expect("create fast path slice directory"); + let usages = [ + "player", + "blocks-and-targets", + "obstacles-and-scene", + "feedback-effects", + ]; + let slices = usages + .iter() + .enumerate() + .map(|(index, usage)| { + let path = format!("assets/art-spritesheet-slices/{usage}.png"); + image::RgbaImage::from_pixel( + 32, + 32, + image::Rgba([80 + index as u8, 140, 220, 180]), + ) + .save(root.join(&path)) + .expect("write fast path slice fixture"); + serde_json::json!({ + "name": format!("素材 {}", index + 1), + "path": path, + "width": 32, + "height": 32, + "usage": usage, + }) + }) + .collect::>(); + fs::write( + root.join("assets/art-spritesheet-slices/manifest.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": "game-art-slices.v1", + "source": "assets/art-spritesheet.png", + "sourceResourceId": "fast-path-art-spritesheet-resource", + "slices": slices, + })) + .expect("serialize fast path slice fixture"), + ) + .expect("write fast path slice manifest"); + } + #[test] fn budgets_leave_a_soft_and_hard_window() { - assert_eq!(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, 240); - assert_eq!(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS, 300); + assert_eq!(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, 4_200); + assert_eq!(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS, 4_500); assert!( GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS < GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS @@ -710,15 +1294,71 @@ mod tests { assert!(html.contains(marker), "missing fallback marker: {marker}"); } assert!(!html.contains("art-spec.png")); - assert!(!html.contains("art-spritesheet.png")); - assert!(!html.contains("drawImage(")); - assert!(html.contains("context.createRadialGradient")); - assert!(html.contains("context.translate(playerX")); + assert!(html.contains("../assets/art-spritesheet-slices/player.png")); + assert!(html.contains("../assets/art-spritesheet-slices/feedback-effects.png")); + assert!(html.contains("context.drawImage(playerArt")); + assert!(html.contains("context.drawImage(targetArt")); + assert!(html.contains("context.drawImage(sceneArt")); + assert!(html.contains("context.drawImage(feedbackArt")); assert!(!html.contains("requestAnimationFrame(draw);\n publish();")); assert_eq!(html.matches("publish();").count(), 3); assert!(html.contains("stateNode.textContent = JSON.stringify(state);")); } + #[test] + fn tetris_fallback_implements_board_fall_rotation_lock_and_line_clear_semantics() { + let html = render_game_chat_fast_path_html("制作水晶俄罗斯方块小游戏"); + + validate_playable_game_html(&html, "game-chat tetris fallback") + .expect("tetris playable contract"); + validate_game_html_smoke(&html).expect("tetris static smoke contract"); + for marker in [ + "data-game-mode=\"tetris\"", + "const COLS = 10", + "const ROWS = 20", + "function rotatePiece()", + "function stepDown()", + "function mergePiece()", + "function clearCompletedRows()", + "row.every(Boolean)", + "旋转方块", + "data-control=\"left\"", + "data-control=\"right\"", + "data-control=\"down\"", + "消行", + ] { + assert!(html.contains(marker), "missing tetris marker: {marker}"); + } + assert!(!html.contains("点击开始,然后操作收集能量")); + assert!(!html.contains("目标能量")); + for path in [ + "../assets/art-spritesheet-slices/player.png", + "../assets/art-spritesheet-slices/blocks-and-targets.png", + "../assets/art-spritesheet-slices/obstacles-and-scene.png", + "../assets/art-spritesheet-slices/feedback-effects.png", + ] { + assert!(html.contains(path), "missing tetris art slice: {path}"); + } + } + + #[test] + fn art_asset_plan_uses_the_canonical_transparent_spritesheet_route() { + let plan = + game_chat_fast_path_canvas_asset_plan("art-asset-plan", "制作原创俄罗斯方块小游戏"); + let action = &plan.actions[0]; + + assert_eq!(action.tool, "canvas.asset_generate"); + assert_eq!(action.input["outputPath"], "assets/art-spritesheet.png"); + assert_eq!(action.input["assetKind"], "art-spritesheet"); + assert_eq!(action.input["aspectRatio"], "1:1"); + assert_eq!(action.input["imageSize"], "1K"); + let prompt = action.input["prompt"].as_str().expect("spritesheet prompt"); + assert!(prompt.contains("左上玩家主体")); + assert!(prompt.contains("方块/目标/危险物")); + assert!(prompt.contains("透明背景")); + assert!(prompt.contains("拆成四张独立素材")); + } + #[test] fn art_director_prompt_keeps_art_spec_as_a_non_runtime_reference() { let plan = @@ -734,7 +1374,7 @@ mod tests { } #[test] - fn fallback_html_is_self_contained_and_does_not_require_art_spec() { + fn fallback_html_uses_spritesheet_but_never_art_spec() { let temporary = tempfile::tempdir().expect("temporary project"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "game-chat-self-contained", "self contained") @@ -746,7 +1386,8 @@ mod tests { .as_str() .expect("fallback html"); assert!(!html.contains("art-spec.png")); - assert!(html.contains("context.createRadialGradient")); + assert!(html.contains("art-spritesheet-slices/player.png")); + assert!(html.contains("context.drawImage")); } #[test] @@ -771,7 +1412,7 @@ mod tests { } #[test] - fn fallback_allows_a_missing_initial_entry() { + fn fallback_allows_a_supported_collection_game_with_a_missing_initial_entry() { let temporary = tempfile::tempdir().expect("temporary project"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "game-chat-missing", "missing game") @@ -779,11 +1420,24 @@ mod tests { fs::remove_file(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)).expect("remove placeholder"); assert!( - game_chat_fast_path_fallback_write_plan_for_root(&root, "制作原创移动躲避小游戏",) + game_chat_fast_path_fallback_write_plan_for_root(&root, "制作原创能量收集小游戏",) .is_ok() ); } + #[test] + fn fallback_fails_closed_for_an_unsupported_gameplay_instead_of_changing_the_game() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-unsupported", "unsupported gameplay") + .expect("initialize project"); + + let error = + game_chat_fast_path_fallback_write_plan_for_root(&root, "制作原创移动躲避小游戏") + .expect_err("unsupported gameplay must not become a collection game"); + assert!(error.contains("没有当前玩法的真实语义模板")); + } + #[test] fn fallback_rejects_a_pure_continue_theme() { let temporary = tempfile::tempdir().expect("temporary project"); @@ -918,6 +1572,9 @@ mod tests { .expect("fallback html content"); assert!(content.contains(original_task)); assert!(!content.contains("data-theme=\"继续\"")); + assert!(content.contains("data-game-mode=\"tetris\"")); + assert!(content.contains("function clearCompletedRows()")); + assert!(!content.contains("点击开始,然后操作收集能量")); } #[test] @@ -954,7 +1611,7 @@ mod tests { } #[test] - fn fallback_budget_preserves_a_baseline_game_and_requests_smoke() { + fn fallback_budget_preserves_a_baseline_game_and_requires_a_real_code_mutation() { let temporary = tempfile::tempdir().expect("temporary project"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "game-chat-baseline", "baseline game") @@ -963,15 +1620,13 @@ mod tests { fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), existing).expect("write baseline game"); let budget = create_game_chat_budget(&root, "game-chat-baseline-root", "继续"); - let plan = game_chat_fast_path_fallback_write_plan_for_budget_at( + let error = game_chat_fast_path_fallback_write_plan_for_budget_at( &root, &budget, "任务 ID:code-prototype", ) - .expect("preserve baseline and request smoke"); - assert_eq!(plan.actions.len(), 1); - assert_eq!(plan.actions[0].tool, "command.run_limited"); - assert_eq!(plan.actions[0].input["commandId"], "game.static_smoke"); + .expect_err("baseline game requires code-prototype to patch before smoke"); + assert!(error.contains("必须先读取并实际 patch")); assert_eq!( fs::read_to_string(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) .expect("read baseline game"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 23cfa014e..87d745f73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1119,7 +1119,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), - content: "首版生成已达到五分钟硬上限,本轮已停止,不会继续在后台运行。" + content: "首版素材生成已达到七十五分钟硬上限,本轮已停止,不会继续在后台运行。" .to_string(), agent_id: None, }, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 6981f58f4..6cae619af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -51,6 +51,70 @@ fn register_game_chat_art_spec_fixture(root: &Path) { .expect("register game-chat art spec"); } +fn register_game_chat_art_spritesheet_fixture(root: &Path) { + image::RgbaImage::from_pixel(64, 64, image::Rgba([90, 140, 220, 0])) + .save(root.join("assets/art-spritesheet.png")) + .expect("write game-chat art spritesheet PNG"); + register_local_asset_at( + root, + "assets/art-spritesheet.png", + "art-spritesheet", + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("game-chat-canvas".to_string()), + resource_id: Some("game-chat-art-spritesheet-resource".to_string()), + asset_object_id: Some("game-chat-art-spritesheet-object".to_string()), + task_id: Some("art-asset-plan".to_string()), + prompt: None, + model: None, + generation_route: Some( + "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + ), + generation_kind: Some("icon-spritesheet".to_string()), + reference_resource_ids: vec!["game-chat-art-spec-resource".to_string()], + }, + ) + .expect("register game-chat art spritesheet"); + let directory = root.join("assets/art-spritesheet-slices"); + fs::create_dir_all(&directory).expect("create game-chat slice fixture directory"); + let usages = [ + "player", + "blocks-and-targets", + "obstacles-and-scene", + "feedback-effects", + ]; + let slices = usages + .iter() + .enumerate() + .map(|(index, usage)| { + let path = format!("assets/art-spritesheet-slices/{usage}.png"); + image::RgbaImage::from_pixel(32, 32, image::Rgba([90 + index as u8, 140, 220, 180])) + .save(root.join(&path)) + .expect("write game-chat slice fixture"); + serde_json::json!({ + "name": format!("素材 {}", index + 1), + "path": path, + "width": 32, + "height": 32, + "usage": usage, + }) + }) + .collect::>(); + fs::write( + root.join("assets/art-spritesheet-slices/manifest.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": "game-art-slices.v1", + "source": "assets/art-spritesheet.png", + "sourceResourceId": "game-chat-art-spritesheet-resource", + "slices": slices, + })) + .expect("serialize game-chat slice fixture"), + ) + .expect("write game-chat slice manifest"); +} + fn queue_game_chat_fast_path_child( root: &Path, root_run_id: &str, @@ -154,7 +218,7 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState ("game/balance.json", r#"{"lives":3,"speed":1}"#), ( "assets/manifest.art.json", - r#"{"assets":[],"status":"not-generated"}"#, + r#"{"assets":[{"path":"assets/art-spritesheet.png"}],"sliceManifest":"assets/art-spritesheet-slices/manifest.json","status":"generated"}"#, ), ("assets/manifest.audio.json", r#"{"bgm":[],"sfx":[]}"#), ("exports/README.md", "# 发布说明\n\n可试玩。\n"), @@ -165,6 +229,7 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState revision }; register_game_chat_art_spec_fixture(root); + register_game_chat_art_spritesheet_fixture(root); for task in new_game_creation_app_seed_tasks() { update_manifest_task_status_at(root, &task.id, GameCreationAppTaskStatus::Completed) .expect("complete autonomous manifest task"); @@ -392,6 +457,66 @@ fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planni assert!(!root.join("assets/manifest.art.json").exists()); } +#[test] +fn game_chat_art_asset_plan_uses_deterministic_icon_spritesheet_generation() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"game-chat-fast-spritesheet-key"}}"#.to_string(), + ); + let temporary = tempfile::tempdir().expect("create game-chat spritesheet root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-art-assets", "水晶俄罗斯方块") + .expect("init game-chat spritesheet project"); + register_game_chat_art_spec_fixture(&root); + let (root_state, child_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-art-assets-root", + "制作水晶俄罗斯方块小游戏", + "art-asset-plan", + ); + let binding = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read game-chat root binding") + .expect("game-chat root binding exists"); + + let plan = game_chat_fast_path_plan_at( + &root, + &child_state, + &child_state.current_task, + binding.bound_at, + ) + .expect("build deterministic art-asset-plan") + .expect("art-asset-plan must bypass Provider planning"); + + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "canvas.asset_generate"); + assert_eq!( + plan.actions[0].input["outputPath"], + "assets/art-spritesheet.png" + ); + assert_eq!(plan.actions[0].input["assetKind"], "art-spritesheet"); + assert_eq!(plan.actions[0].input["replaceExisting"], false); + assert!(!root.join("assets/manifest.art.json").exists()); + fs::write( + root.join("assets/manifest.art.json"), + game_chat_fast_path_art_manifest_content(), + ) + .expect("write generated art manifest fixture"); + register_game_chat_art_spritesheet_fixture(&root); + let completion_plan = game_chat_fast_path_plan_at( + &root, + &child_state, + &child_state.current_task, + binding.bound_at, + ) + .expect("build deterministic art completion plan") + .expect("art completion plan exists"); + assert!(completion_plan.actions.is_empty()); + assert!(completion_plan.response.contains("透明核心美术图集已生成")); +} + #[test] fn game_chat_art_stage_fails_before_provider_when_editor_api_is_missing() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); @@ -552,6 +677,103 @@ fn game_chat_code_prototype_ignores_art_director_global_revision_before_its_own_ ); } +#[test] +fn game_chat_existing_game_requires_code_mutation_before_smoke_and_then_converges() { + let temporary = tempfile::tempdir().expect("create existing game code root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-existing-code", "水晶俄罗斯方块") + .expect("init existing game project"); + let existing = + ""; + fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), existing) + .expect("write existing non-placeholder game"); + let (root_state, mut code_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-existing-code-root", + "继续完成水晶俄罗斯方块", + "code-prototype", + ); + code_state.loop_iteration = 2; + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read existing game root binding") + .expect("existing game root binding exists") + .bound_at; + + let before_mutation = game_chat_fast_path_plan_at( + &root, + &code_state, + &code_state.current_task, + bound_at + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, + ) + .expect("evaluate existing game before code mutation"); + assert!( + before_mutation.is_none(), + "existing game must return to the code Provider instead of repeating game.static_smoke" + ); + + { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.game-chat.existing-code.mutate-and-verify", + ) + .expect("acquire existing game mutation lock"); + let revision = prepare_agent_runtime_project_mutation_locked( + &root, + &code_state.agent_id, + &code_state.run_id, + "file.patch", + ) + .expect("advance existing game for code child"); + assert_eq!(revision, 1); + write_local_project_file_at( + &root, + AGENT_RUNTIME_GAME_INDEX_PATH, + &format!("{existing}"), + ) + .expect("patch existing game"); + let (revision, gate) = begin_agent_runtime_project_verification_locked( + &root, + &code_state.agent_id, + &code_state.run_id, + "game.static_smoke", + ) + .expect("begin existing game smoke"); + finish_agent_runtime_project_verification_locked(&root, &revision, gate, true) + .expect("finish existing game smoke"); + } + + let delivery = game_chat_fast_path_plan_at( + &root, + &code_state, + &code_state.current_task, + bound_at + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, + ) + .expect("evaluate existing game after code mutation") + .expect("verified code child uses deterministic delivery"); + assert!(delivery.actions.is_empty()); + assert!(delivery.response.contains("通过静态自检")); + let code_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &code_state.agent_id, + &code_state.run_id, + ) + .expect("read converged code gate"); + assert_eq!(code_gate.mutation_revision, Some(1)); + assert_eq!(code_gate.verified_revision, Some(1)); + validate_agent_runtime_autonomous_specialist_response_delivery( + &code_state.agent_id, + &code_state.run_id, + false, + &code_gate, + &delivery, + ) + .expect("specialist delivery accepts the code child's own verified mutation"); +} + #[test] fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() { assert_eq!( @@ -623,6 +845,7 @@ fn game_chat_single_round_converges_without_another_provider_plan_after_playtest "design-director" | "art-director" | "code-director" + | "art-asset-plan" | "code-prototype" | "preview-readiness" | "preview-playtest" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 115d41968..22304db10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -664,10 +664,15 @@ pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source( "design-director" => Some(Vec::new()), "art-director" => Some(Vec::new()), "code-director" => Some(Vec::new()), + "art-asset-plan" => Some(vec![ + "design-director".to_string(), + "art-director".to_string(), + ]), "code-prototype" => Some(vec![ "design-director".to_string(), "art-director".to_string(), "code-director".to_string(), + "art-asset-plan".to_string(), ]), "preview-readiness" => Some(vec!["code-prototype".to_string()]), "preview-playtest" => Some(vec!["preview-readiness".to_string()]), @@ -1170,7 +1175,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke )?; let game_chat_requires_visual_asset = root_parent_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE - && manifest_task.id == "art-director"; + && matches!(manifest_task.id.as_str(), "art-director" | "art-asset-plan"); if status == GameCreationAppTaskStatus::Completed && (autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id) || game_chat_requires_visual_asset) @@ -1330,7 +1335,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( let code_visual_asset_requirement = if task.id == "code-prototype" && editor_api_key_is_configured() { - " External Editor API 已配置时必须先调用 asset.list 核对 Canvas 登记与资源有效性:GUI/CLI Run 使用独立派生的 assets/art-spritesheet.png,并在 game/index.html 中通过 HTML、CSS background 或 Canvas drawImage 真实可见使用;game-chat Run 的 assets/art-spec.png 只是视觉规范参考,禁止把整张规范图铺成背景或从中裁切玩家、目标和场景实体,没有独立派生图集时应使用与规范一致的自包含原创 Canvas 视觉。" + " External Editor API 已配置时必须先调用 asset.list 核对 Canvas 登记与资源有效性。所有 Run 都必须等待独立派生的 assets/art-spritesheet.png;game-chat 还必须读取 assets/art-spritesheet-slices/manifest.json,并在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类独立透明切片。assets/art-spec.png 只作视觉规范参考,禁止猜测图集等分坐标、把整张规范图或整张图集作为背景、直接图片或 CSS background 冒充运行时素材,也禁止以纯代码几何替代核心实体。" } else { "" }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 6b77c5f5a..a935d1267 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -385,6 +385,7 @@ fn autonomous_code_prototype_art_asset_reference_gap_at( root: &Path, task_id: &str, required_visual_task_id: Option<&str>, + require_core_slices: bool, ) -> Result, String> { if task_id != "code-prototype" { return Ok(None); @@ -392,11 +393,6 @@ fn autonomous_code_prototype_art_asset_reference_gap_at( let Some(required_visual_task_id) = required_visual_task_id else { return Ok(None); }; - if required_visual_task_id == "art-director" { - // game-chat still requires art-director to deliver a valid visual specification, - // but the specification is a design reference rather than a runtime sprite/background. - return Ok(None); - } let (asset_path, asset_label) = match required_visual_task_id { "art-asset-plan" => ("assets/art-spritesheet.png", "art-spritesheet"), _ => { @@ -434,7 +430,20 @@ fn autonomous_code_prototype_art_asset_reference_gap_at( "game/index.html(missing-visible-{asset_label}-use:{asset_path})" ))); }; - if !game_index_visibly_uses_visual_asset(&html, asset_path, asset_dimensions, false) { + if require_core_slices { + match game_index_missing_visible_art_slice(root, &html) { + Err(gap) => return Ok(Some(gap)), + Ok(Some(gap)) => return Ok(Some(gap)), + Ok(None) => {} + } + } else if !matches!(game_index_missing_visible_art_slice(root, &html), Ok(None)) + && !game_index_visibly_uses_visual_asset( + &html, + asset_path, + asset_dimensions, + VisualAssetUsageRequirement::AtlasCanvasCrop, + ) + { return Ok(Some(format!( "game/index.html(missing-visible-{asset_label}-use:{asset_path})" ))); @@ -442,6 +451,37 @@ fn autonomous_code_prototype_art_asset_reference_gap_at( Ok(None) } +fn game_index_missing_visible_art_slice( + root: &Path, + html: &[u8], +) -> Result, String> { + let slice_paths = game_chat_fast_path_art_slice_paths(root).map_err(|error| { + format!( + "assets/art-spritesheet-slices/manifest.json(invalid:{})", + sanitize_agent_runtime_text(&error, 240) + ) + })?; + for slice_path in slice_paths { + let dimensions = resolve_local_project_path(root, &slice_path) + .ok() + .and_then(|path| fs::read(path).ok()) + .and_then(|bytes| image::load_from_memory(&bytes).ok()) + .map(|image| (image.width(), image.height())) + .ok_or_else(|| format!("无法读取已验证视觉切片尺寸:{slice_path}"))?; + if !game_index_visibly_uses_visual_asset( + html, + &slice_path, + dimensions, + VisualAssetUsageRequirement::CanvasDraw, + ) { + return Ok(Some(format!( + "game/index.html(missing-visible-art-slice-use:{slice_path})" + ))); + } + } + Ok(None) +} + fn strip_art_reference_comments(content: &str) -> String { let bytes = content.as_bytes(); let mut output = Vec::with_capacity(bytes.len()); @@ -1099,11 +1139,18 @@ fn tag_visibly_uses_visual_asset( .is_some_and(|style| css_contains_resolving_url(style, asset_path)) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VisualAssetUsageRequirement { + AnyVisible, + CanvasDraw, + AtlasCanvasCrop, +} + fn game_index_visibly_uses_visual_asset( html: &[u8], asset_path: &str, asset_dimensions: (u32, u32), - require_canvas_composition: bool, + requirement: VisualAssetUsageRequirement, ) -> bool { let asset_path = asset_path.to_ascii_lowercase(); let Ok(html) = std::str::from_utf8(html) else { @@ -1123,7 +1170,7 @@ fn game_index_visibly_uses_visual_asset( }; let end = start + end_offset + 1; let tag = &markup[start..end]; - if !require_canvas_composition + if requirement == VisualAssetUsageRequirement::AnyVisible && tag_visibly_uses_visual_asset(tag, &asset_path, asset_dimensions) && !tag_is_hidden_by_stylesheet(tag, &markup, asset_dimensions) { @@ -1148,7 +1195,7 @@ fn game_index_visibly_uses_visual_asset( let Some((selector, declarations)) = rule.rsplit_once('{') else { continue; }; - if !require_canvas_composition + if requirement == VisualAssetUsageRequirement::AnyVisible && css_contains_resolving_url(declarations, &asset_path) && selector_is_bound_to_markup(selector, &markup) && !tag_is_obviously_hidden_or_tiny( @@ -1176,8 +1223,6 @@ fn game_index_visibly_uses_visual_asset( return false; } let mut significant_draws = 0usize; - let mut background_draws = 0usize; - let mut entity_draws = 0usize; let function_ranges = named_javascript_function_ranges(&content); let mut draw_cursor = 0; while let Some(offset) = content[draw_cursor..].find("drawimage(") { @@ -1197,21 +1242,44 @@ fn game_index_visibly_uses_visual_asset( .first() .is_some_and(|identifier| identifiers.contains(*identifier)) { - let (significant, background, entity) = - draw_image_metrics(&arguments, asset_dimensions); + let (significant, _, _) = draw_image_metrics(&arguments, asset_dimensions); significant_draws += usize::from(significant); - background_draws += usize::from(background); - entity_draws += usize::from(entity); - if !require_canvas_composition && significant { + if requirement == VisualAssetUsageRequirement::AnyVisible && significant { + return true; + } + if requirement == VisualAssetUsageRequirement::CanvasDraw + && matches!(arguments.len(), 5 | 9) + && draw_image_has_visible_destination(&arguments) + { + return true; + } + if requirement == VisualAssetUsageRequirement::AtlasCanvasCrop + && arguments.len() == 9 + && draw_image_has_visible_destination(&arguments) + { return true; } } draw_cursor = arguments_end + 1; } - require_canvas_composition - && significant_draws >= 3 - && background_draws >= 1 - && entity_draws >= 2 + requirement == VisualAssetUsageRequirement::AnyVisible && significant_draws > 0 +} + +fn draw_image_has_visible_destination(arguments: &[&str]) -> bool { + let (width_index, height_index) = match arguments.len() { + 5 => (3, 4), + 9 => (7, 8), + _ => return false, + }; + let parse = |value: &str| value.trim().parse::().ok(); + match parse(arguments[width_index]).zip(parse(arguments[height_index])) { + Some((width, height)) => { + width.abs() >= 32.0 && height.abs() >= 32.0 && width.abs() * height.abs() >= 2048.0 + } + None => { + !arguments[width_index].trim().is_empty() && !arguments[height_index].trim().is_empty() + } + } } pub(in crate::agent) fn game_creation_app_task_status_label( @@ -1236,7 +1304,7 @@ fn autonomous_manifest_parent_completion_gaps_at( .ok_or_else(|| "自主构建根 Supervisor Run 缺少 Run Profile 绑定".to_string())?; let seed_tasks = crate::agent::autonomous_manifest_seed_tasks_for_source(&binding.source); let required_visual_task_id = if binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { - Some("art-director") + Some("art-asset-plan") } else if editor_api_key_is_configured() { Some("art-asset-plan") } else { @@ -1260,7 +1328,13 @@ fn autonomous_manifest_parent_completion_gaps_at( contract.baseline_index_sha256.as_deref(), &contract.baseline_artifacts, )?); - if required_visual_task_id == Some(seed_task.id.as_str()) { + let requires_visual_registration = + if binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + matches!(seed_task.id.as_str(), "art-director" | "art-asset-plan") + } else { + required_visual_task_id == Some(seed_task.id.as_str()) + }; + if requires_visual_registration { if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, &seed_task.id) { @@ -1276,6 +1350,7 @@ fn autonomous_manifest_parent_completion_gaps_at( root, &seed_task.id, required_visual_task_id, + binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, )? { missing_paths.push(AutonomousManifestArtifactGap::new(gap)); } @@ -1482,13 +1557,18 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( } }; let required_visual_task_id = if root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { - Some("art-director") + Some("art-asset-plan") } else if editor_api_key_is_configured() { Some("art-asset-plan") } else { None }; - if required_visual_task_id == Some(state.agent_id.as_str()) { + let requires_visual_registration = if root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + matches!(state.agent_id.as_str(), "art-director" | "art-asset-plan") + } else { + required_visual_task_id == Some(state.agent_id.as_str()) + }; + if requires_visual_registration { if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, &state.agent_id) { @@ -1506,6 +1586,7 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( root, &state.agent_id, required_visual_task_id, + root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, ) { Ok(Some(gap)) => { return Some(autonomous_completion_blocker( @@ -1823,7 +1904,8 @@ pub(in crate::agent) fn is_pure_autonomous_continuation_intent(task: &str) -> bo }) .chars() .filter(|character| !character.is_whitespace()) - .collect::(); + .collect::() + .to_ascii_lowercase(); matches!( normalized.as_str(), "继续" @@ -1848,6 +1930,9 @@ pub(in crate::agent) fn is_pure_autonomous_continuation_intent(task: &str) -> bo | "继续之前的任务" | "继续完成之前的任务" | "接着完成之前的任务" + | "接着" + | "continue" + | "goon" ) } @@ -1899,6 +1984,9 @@ fn failed_terminal_autonomous_root_contract_before_task_at( .into_iter() .find(|record| record.run_id == *previous_run_id) .ok_or_else(|| "自主构建续跑缺少上一根 Run 的最新 journal 投影".to_string())?; + if previous.source != task.source { + return Ok(None); + } if !matches!( game_creator_agent_runtime_terminal_status(&previous), Some("failed" | "cancelled" | "budget-exhausted") @@ -1913,13 +2001,19 @@ fn failed_terminal_autonomous_root_contract_before_task_at( else { return Ok(None); }; - let previous_task_sha256 = format!("{:x}", Sha256::digest(previous.task.as_bytes())); + let previous_task = autonomous_effective_root_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &previous.run_id, + &previous.task, + )?; + let previous_task_sha256 = format!("{:x}", Sha256::digest(previous_task.as_bytes())); if contract.task_sha256 != previous_task_sha256 { return Err("自主构建续跑的上一根 Run 任务语义与完成合同不一致".to_string()); } Ok(Some(AutonomousContinuationInheritance { contract, - task: previous.task, + task: previous_task, })) } @@ -2392,8 +2486,14 @@ pub(in crate::agent) fn autonomous_completion_contract_for_state_at( } let contract = read_autonomous_completion_contract(root, &state.agent_id, &state.run_id)? .ok_or_else(|| "自主构建根 Supervisor Run 缺少完成合同".to_string())?; + let effective_task = autonomous_effective_root_task_at( + root, + &state.agent_id, + &state.run_id, + &state.current_task, + )?; if contract.run_profile_binding_fingerprint != state.run_profile_binding_fingerprint - || contract.task_sha256 != format!("{:x}", Sha256::digest(state.current_task.as_bytes())) + || contract.task_sha256 != format!("{:x}", Sha256::digest(effective_task.as_bytes())) { return Err("自主构建完成合同与当前 Runtime 不匹配".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index f23ccf991..af125c079 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -72,7 +72,7 @@ fn autonomous_supervisor_source_allowlist_includes_game_chat_only() { } #[test] -fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_task_lane() { +fn game_chat_manifest_seed_projection_requires_art_assets_before_code() { let game_chat_tasks = autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); assert_eq!( @@ -83,6 +83,7 @@ fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_tas [ "design-director", "art-director", + "art-asset-plan", "code-director", "code-prototype", "preview-readiness", @@ -91,21 +92,26 @@ fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_tas ); assert_eq!(game_chat_tasks[0].dependencies, Vec::::new()); assert_eq!(game_chat_tasks[1].dependencies, Vec::::new()); - assert_eq!(game_chat_tasks[2].dependencies, Vec::::new()); assert_eq!( - game_chat_tasks[3].dependencies, + game_chat_tasks[2].dependencies, + vec!["design-director".to_string(), "art-director".to_string(),] + ); + assert_eq!(game_chat_tasks[3].dependencies, Vec::::new()); + assert_eq!( + game_chat_tasks[4].dependencies, vec![ "design-director".to_string(), "art-director".to_string(), "code-director".to_string(), + "art-asset-plan".to_string(), ] ); assert_eq!( - game_chat_tasks[4].dependencies, + game_chat_tasks[5].dependencies, vec!["code-prototype".to_string()] ); assert_eq!( - game_chat_tasks[5].dependencies, + game_chat_tasks[6].dependencies, vec!["preview-readiness".to_string()] ); @@ -127,6 +133,7 @@ fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_tas "design-director" | "art-director" | "code-director" + | "art-asset-plan" | "code-prototype" | "preview-readiness" | "preview-playtest" @@ -153,6 +160,18 @@ fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_tas .unwrap_or_else(|| panic!("{task_id} task exists")) .status = GameCreationAppTaskStatus::Completed; } + assert_eq!( + autonomous_manifest_ready_task_ids( + &manifest_tasks, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ), + vec!["art-asset-plan".to_string()] + ); + manifest_tasks + .iter_mut() + .find(|task| task.id == "art-asset-plan") + .expect("art-asset-plan task exists") + .status = GameCreationAppTaskStatus::Completed; assert_eq!( autonomous_manifest_ready_task_ids( &manifest_tasks, @@ -162,6 +181,10 @@ fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_tas ); } +fn cropped_spritesheet_game_html() -> &'static str { + "" +} + fn autonomous_fixture_with_setup( task: &str, run_id: &str, @@ -280,6 +303,42 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) { register_autonomous_visual_fixture(root, "assets/art-spec.png", "icon-spec"); register_autonomous_visual_fixture(root, "assets/ui-prototype.png", "ui-prototype"); register_autonomous_visual_fixture(root, "assets/art-spritesheet.png", "art-spritesheet"); + let directory = root.join("assets/art-spritesheet-slices"); + fs::create_dir_all(&directory).expect("create autonomous slice fixture directory"); + let usages = [ + "player", + "blocks-and-targets", + "obstacles-and-scene", + "feedback-effects", + ]; + let slices = usages + .iter() + .enumerate() + .map(|(index, usage)| { + let path = format!("assets/art-spritesheet-slices/{usage}.png"); + image::RgbaImage::from_pixel(32, 32, image::Rgba([90 + index as u8, 140, 220, 180])) + .save(root.join(&path)) + .expect("write autonomous slice fixture"); + serde_json::json!({ + "name": format!("素材 {}", index + 1), + "path": path, + "width": 32, + "height": 32, + "usage": usage, + }) + }) + .collect::>(); + fs::write( + root.join("assets/art-spritesheet-slices/manifest.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": "game-art-slices.v1", + "source": "assets/art-spritesheet.png", + "sourceResourceId": "resource-art-spritesheet", + "slices": slices, + })) + .expect("serialize autonomous slice fixture"), + ) + .expect("write autonomous slice manifest"); for task in new_game_creation_app_seed_tasks() { update_manifest_task_status_at(root, &task.id, GameCreationAppTaskStatus::Completed) .expect("complete autonomous seed task fixture"); @@ -536,7 +595,15 @@ fn append_failed_autonomous_root_projection( #[test] fn autonomous_continuation_intent_is_exact_and_does_not_swallow_new_requirements() { - for task in ["继续", " 继续完成。 ", "接着做", "请继续完成吧!"] { + for task in [ + "继续", + " 继续完成。 ", + "接着", + "接着做", + "请继续完成吧!", + "continue", + "Go on!", + ] { assert!(is_pure_autonomous_continuation_intent(task), "task={task}"); } for task in [ @@ -551,8 +618,11 @@ fn autonomous_continuation_intent_is_exact_and_does_not_swallow_new_requirements #[test] fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress() { let original_task = "做一个水晶主题的俄罗斯方块,完成移动、旋转、消行和重开"; - let (_temporary, root, original_state, original_contract) = - autonomous_fixture(original_task, "crystal-tetris-original-run"); + let (_temporary, root, original_state, original_contract) = autonomous_fixture_with_source( + original_task, + "crystal-tetris-original-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); let original_record = read_latest_game_creator_agent_runtime_task_by_run_id( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -620,6 +690,30 @@ fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress( .expect("resolve continued root semantics"), original_task ); + let continuation_state = agent_runtime_state_from_task_record(&continuation); + assert_eq!( + autonomous_completion_contract_for_state_at(&root, &continuation_state) + .expect("continued runtime must accept its inherited task semantics") + .expect("continued runtime keeps an autonomous completion contract"), + continuation_contract + ); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Pending) + .expect("make one inherited task ready for scheduler validation"); + let scheduled = schedule_autonomous_game_build_ready_tasks_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &continuation.run_id, + 3, + ) + .expect("continued root must pass the scheduler contract gate"); + assert_eq!(scheduled.len(), 1); + assert_eq!(scheduled[0].state.agent_id, "design-director"); + update_manifest_task_status_at( + &root, + "design-director", + GameCreationAppTaskStatus::Completed, + ) + .expect("restore inherited task completion after scheduler validation"); assert!(read_manifest_for_project(&root) .expect("read continued tetris manifest") .tasks @@ -638,6 +732,52 @@ fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress( .expect("continued contract remains present"), continuation_contract ); + + append_failed_autonomous_root_projection(&root, &continuation, "budget-exhausted"); + let second_continuation = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.session_id, + "继续", + "crystal-tetris-second-continuation-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue a second continuation after the successor also fails"); + let second_contract = read_autonomous_completion_contract( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &second_continuation.run_id, + ) + .expect("read second continuation contract") + .expect("second continuation contract exists"); + assert_eq!(second_contract.task_sha256, original_contract.task_sha256); + assert_eq!( + autonomous_effective_root_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &second_continuation.run_id, + &second_continuation.task, + ) + .expect("resolve semantics through consecutive continuations"), + original_task + ); + let second_state = agent_runtime_state_from_task_record(&second_continuation); + assert_eq!( + autonomous_completion_contract_for_state_at(&root, &second_state) + .expect("second continuation must pass runtime contract validation") + .expect("second continuation keeps the inherited contract"), + second_contract + ); + assert!(schedule_autonomous_game_build_ready_tasks_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &second_continuation.run_id, + 3, + ) + .expect("second continuation must pass the scheduler contract gate") + .is_empty()); } #[test] @@ -743,6 +883,56 @@ fn game_chat_pure_continue_does_not_inherit_across_sessions() { .all(|task| task.status == GameCreationAppTaskStatus::Pending)); } +#[test] +fn game_chat_pure_continue_does_not_inherit_across_supervisor_sources() { + let (_temporary, root, original_state, original_contract) = autonomous_fixture_with_source( + "做一个水晶主题的俄罗斯方块", + "cross-source-original-run", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ); + let original_record = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.run_id, + ) + .expect("read cross-source original root") + .expect("cross-source original root exists"); + append_failed_autonomous_root_projection(&root, &original_record, "failed"); + + let continuation = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.session_id, + "继续", + "cross-source-continuation-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue cross-source continuation"); + let continuation_contract = read_autonomous_completion_contract( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &continuation.run_id, + ) + .expect("read cross-source continuation contract") + .expect("cross-source continuation contract exists"); + + assert_eq!( + continuation_contract.task_sha256, + format!("{:x}", Sha256::digest("继续".as_bytes())) + ); + assert_ne!( + continuation_contract.task_sha256, + original_contract.task_sha256 + ); + assert!(read_manifest_for_project(&root) + .expect("read cross-source reset manifest") + .tasks + .iter() + .all(|task| task.status == GameCreationAppTaskStatus::Pending)); +} + #[test] fn autonomous_completion_contract_recovery_rejects_v1_and_missing_baseline_artifacts() { let (_temporary, root, state, contract) = autonomous_fixture( @@ -1331,6 +1521,7 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task "design-director" | "art-director" | "code-director" + | "art-asset-plan" | "code-prototype" | "preview-readiness" | "preview-playtest" @@ -1339,11 +1530,7 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); } } - let revision = advance_game_index_revision( - &root, - &state, - "", - ); + let revision = advance_game_index_revision(&root, &state, cropped_spritesheet_game_html()); mark_verification_passed(&root, &state, "game.static_smoke"); let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario); let action = AgentRuntimeToolAction { @@ -1401,7 +1588,7 @@ fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary } #[test] -fn game_chat_code_prototype_treats_art_spec_as_reference_not_runtime_asset() { +fn game_chat_code_prototype_requires_cropped_spritesheet_use() { let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"game-chat-art-gate-key"}}"#.to_string(), ); @@ -1420,10 +1607,45 @@ fn game_chat_code_prototype_treats_art_spec_as_reference_not_runtime_asset() { &code_state, "", ); - assert!( - autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(), - "game-chat code must not require the visual specification in the runtime DOM or Canvas" + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("pure code core visuals must be rejected"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("assets/art-spritesheet-slices/player.png"))); + + advance_game_index_revision( + &root, + &code_state, + "", ); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(), + "showing the four slices outside the active game canvas must not satisfy runtime use" + ); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(), + "drawing the complete atlas must not count as cropped runtime use" + ); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(), + "guessing one atlas crop must not replace four persisted core slices" + ); + + advance_game_index_revision(&root, &code_state, cropped_spritesheet_game_html()); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); } #[test] @@ -1455,11 +1677,14 @@ fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_co &code_state, "", ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision(&root, &code_state, cropped_spritesheet_game_html()); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); } #[test] -fn game_chat_code_prototype_does_not_require_art_spec_without_editor_configuration() { +fn game_chat_code_prototype_fails_closed_without_generated_spritesheet() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( "创建一轮程序化水晶小游戏", @@ -1471,14 +1696,20 @@ fn game_chat_code_prototype_does_not_require_art_spec_without_editor_configurati let code_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); let code_state = agent_runtime_state_from_task_record(&code_record); - fs::remove_file(root.join("assets/art-spec.png")).expect("remove optional art-spec file"); + fs::remove_file(root.join("assets/art-spritesheet.png")) + .expect("remove required art-spritesheet file"); advance_game_index_revision( &root, &code_state, "", ); - assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("game-chat must not fall back to programmatic core visuals"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("assets/art-spritesheet.png"))); } #[test] @@ -1554,11 +1785,7 @@ fn game_chat_later_code_child_rejects_pending_then_projects_verified_completion( let code_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); let mut code_state = agent_runtime_state_from_task_record(&code_record); - advance_game_index_revision( - &root, - &code_state, - "", - ); + advance_game_index_revision(&root, &code_state, cropped_spritesheet_game_html()); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) .expect("later code child must keep the strict manifest status gate"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 578f701b8..1f93515df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -672,6 +672,12 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } + let game_chat_requires_core_slices = if agent_id == "art-asset-plan" { + agent_runtime_root_source_at(root, agent_id, run_id) + .is_ok_and(|source| source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) + } else { + false + }; let resumes_durable_generation = match pending_action { Some(pending) => match platform_art_generation_runtime_recovery_at(root, pending) { Ok(PlatformArtGenerationRuntimeRecovery::Missing) => false, @@ -733,6 +739,17 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }; } }; + if game_chat_requires_core_slices && prepared.slice_count() != 4 { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: format!( + "透明图集生成结果包含 {} 个独立切片;game-chat 必须恰好得到玩家、目标、场景和反馈四类真实素材,已在正式图集登记前失败关闭", + prepared.slice_count() + ), + detail: None, + }; + } let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "canvas.asset_generate", @@ -786,19 +803,49 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio ); } }; - match commit_prepared_platform_art_asset_at(root, prepared, &options, |_| { - let output_path = options - .output_path - .as_deref() - .expect("replaceExisting commit guard requires outputPath"); - validate_agent_runtime_canvas_replacement_authorization_at( - root, - agent_id, - run_id, - output_path, - ) - }) { + let committed = if game_chat_requires_core_slices { + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + let output_path = options + .output_path + .as_deref() + .expect("replaceExisting commit guard requires outputPath"); + validate_agent_runtime_canvas_replacement_authorization_at( + root, + agent_id, + run_id, + output_path, + ) + }) + } else { + commit_prepared_platform_art_asset_at(root, prepared, &options, |_| { + let output_path = options + .output_path + .as_deref() + .expect("replaceExisting commit guard requires outputPath"); + validate_agent_runtime_canvas_replacement_authorization_at( + root, + agent_id, + run_id, + output_path, + ) + }) + }; + match committed { Ok(generated) => { + if game_chat_requires_core_slices { + if let Err(error) = write_local_project_file_at( + root, + "assets/manifest.art.json", + &game_chat_fast_path_art_manifest_content(), + ) { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "透明图集与四类切片已生成,但正式美术清单提交失败".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + } let verification = begin_agent_runtime_project_verification_locked( root, agent_id, @@ -829,6 +876,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio "assetObjectId": generated.asset_object_id.clone(), "taskId": generated.task_id.clone(), "model": generated.model.clone(), + "sliceCount": generated.slices.len(), }), ); let slice_warning_summary = generated @@ -849,7 +897,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio generated.asset.local_path ), detail: Some(format!( - "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, warning={}, sliceWarning={}, verifiedRevision={mutation_revision}", + "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, warning={}, sliceWarning={}, sliceCount={}, verifiedRevision={mutation_revision}", generated.asset.id, generated.asset.local_path, generated.resource_id.as_deref().unwrap_or(""), @@ -857,7 +905,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio generated.task_id.as_deref().unwrap_or(""), generated.model.as_deref().unwrap_or(""), generated.warning.as_deref().unwrap_or(""), - generated.slice_warning.as_deref().unwrap_or("") + generated.slice_warning.as_deref().unwrap_or(""), + generated.slices.len() )), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index c005c80e3..630fd80fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -228,6 +228,7 @@ mod tests { for task_id in [ "design-director", "art-director", + "art-asset-plan", "code-director", "code-prototype", "preview-readiness", @@ -249,13 +250,13 @@ mod tests { assert!(!detail.contains("publish-package"), "{detail}"); assert!( detail.contains( - "seedTaskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6" + "seedTaskCounts: completed=7 running=0 pending=0 waiting=0 failed=0 total=7" ), "{detail}" ); assert!( detail - .contains("taskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6"), + .contains("taskCounts: completed=7 running=0 pending=0 waiting=0 failed=0 total=7"), "{detail}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ac768693f..d07775516 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -881,9 +881,20 @@ struct SyncCanvasProjectAssetsResult { assets: Vec, } +#[derive(Debug, Eq, PartialEq)] +struct GeneratedPlatformArtAssetSlice { + name: String, + width: u32, + height: u32, + local_path: String, + resource_id: Option, + asset_object_id: Option, +} + #[derive(Debug, Eq, PartialEq)] struct GeneratedPlatformArtAsset { asset: UploadLocalAssetResult, + slices: Vec, resource_id: Option, asset_object_id: Option, task_id: Option, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7d2652110..473b7dbad 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -270,6 +270,7 @@ type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { const GAME_CHAT_STAGE_TASK_IDS = [ 'design-director', 'art-director', + 'art-asset-plan', 'code-director', 'code-prototype', 'preview-readiness', diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index 7ebbbec4c..45596a497 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -60,6 +60,7 @@ const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:'; const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([ 'design-director', 'art-director', + 'art-asset-plan', 'code-director', 'code-prototype', 'preview-readiness', @@ -328,6 +329,7 @@ export function buildGameChatProgressEvidence( const fastPathTaskIds = new Set([ 'design-director', 'art-director', + 'art-asset-plan', 'code-director', 'code-prototype', 'preview-readiness', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index f204885be..059d4472c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -135,6 +135,7 @@ function gameChatRuntimeState( const GAME_CHAT_STAGE_TASK_IDS = [ 'design-director', 'art-director', + 'art-asset-plan', 'code-director', 'code-prototype', 'preview-readiness', @@ -2859,10 +2860,11 @@ export function registerProjectSupervisorSurfaceTests() { requestKind: 'tool-plan', }, ]); - expect(messages).toHaveLength(6); + expect(messages).toHaveLength(7); expect(messages.map((message) => message.text)).toEqual([ expect.stringContaining('玩法方向已完成'), expect.stringContaining('视觉方向已完成'), + expect.stringContaining('平台美术图集已生成并登记'), expect.stringContaining('程序方案已完成'), expect.stringContaining('代码原型已完成'), expect.stringContaining('预览就绪检查已完成'), @@ -2990,7 +2992,7 @@ export function registerProjectSupervisorSurfaceTests() { let rendered = renderRelease(); await waitFor(() => { expect(screen.getByText(/视觉方向已完成/)).not.toBeNull(); - expect(screen.queryByText(/平台美术图集已生成并登记/)).toBeNull(); + expect(screen.getByText(/平台美术图集已生成并登记/)).not.toBeNull(); expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); expect(screen.getByText(/预览就绪已完成/)).not.toBeNull(); expect(screen.getByText(/试玩验证已完成/)).not.toBeNull(); @@ -3002,14 +3004,14 @@ export function registerProjectSupervisorSurfaceTests() { String(args?.messageId ?? '').startsWith('game-chat-final-reply:'), ); await waitFor(() => { - expect(finalReplyAppends()).toHaveLength(6); + expect(finalReplyAppends()).toHaveLength(7); }); rendered.unmount(); rendered = renderRelease(); await waitFor(() => { expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); }); - expect(finalReplyAppends()).toHaveLength(6); + expect(finalReplyAppends()).toHaveLength(7); rendered.unmount(); }); @@ -3572,7 +3574,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)'); }); - it('counts only the six first-playable tasks in game-chat progress', () => { + it('counts only the seven first-playable tasks in game-chat progress', () => { const manifest = createGameCreationAppManifest( 'game-chat-progress-total', 'game-chat-progress-total', @@ -3592,7 +3594,7 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime, manifest }); const progress = screen.getByLabelText('Supervisor 进度播报'); - expect(progress.textContent).toContain('任务图 6/6'); + expect(progress.textContent).toContain('任务图 7/7'); expect(progress.textContent).not.toContain('publish-strategy'); expect(progress.textContent).not.toContain('publish-package'); }); @@ -3772,7 +3774,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); expect(within(progress).queryByText(/第 4 轮/u)).toBeNull(); expect( - within(progress).getByText('任务图 3/6 · 进行中 1 · 计划 1/3'), + within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 1/3'), ).not.toBeNull(); expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull(); expect(within(progress).getByText('活跃专业 Agent')).not.toBeNull(); @@ -3841,7 +3843,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); expect(within(progress).queryByText(/第 5 轮/u)).toBeNull(); expect( - within(progress).getByText('任务图 3/6 · 进行中 1 · 计划 2/3'), + within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 2/3'), ).not.toBeNull(); expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull(); expect(within(progress).getByText('返工决定')).not.toBeNull(); @@ -4307,6 +4309,7 @@ export function registerProjectSupervisorSurfaceTests() { : [ 'design-director', 'art-director', + 'art-asset-plan', 'code-director', 'code-prototype', 'preview-readiness', @@ -4448,7 +4451,7 @@ export function registerProjectSupervisorSurfaceTests() { (stageRecordAppends()[0]?.[1] as { message?: { content?: string } }) ?.message?.content ?? '', ); - expect(stageRecord).toContain('5/6'); + expect(stageRecord).toContain('6/7'); }); it('archives a terminal game-chat run restored during initial hydration exactly once', async () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0700de375..c185afd03 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5914,17 +5914,17 @@ ## 2026-08-03 game-chat 开发态前后端同源与快车道恢复 - 启动决策:`npm run agc` 与 `npm run agc:game-chat` 统一先经外层 Node 启动器预检 `3080`。在 marker 尚不能证明 worktree 归属时,任何已占用的 3080 都不得复用,并必须在原生窗口创建前失败关闭;Tauri CLI 退出后必须收束已启动的客户端进程树,不允许终端已退出但窗口与 Runner 仍假在线。 -- 首波决策:普通 GUI / CLI 的正式 16 节点 DAG 与 game-chat 首版 lane 都只把 `design-director / art-director / code-director` 作为首波 ready Agent。正式 DAG 的 `design-foundation` 等待策划与美术 Director,`code-prototype` 等待程序 Director 及数值、美术、音频三条底层产物链;game-chat 则在三个 Director 全部完成后才启动 `code-prototype`,再串行执行静态检查和试玩。首波以外的非 repair 底层 Agent 只能由依赖就绪调度或上层明确返工合同按需激活。 -- Runtime 决策:game-chat 六任务 lane、平台美术、单轮收束和自动预览只由持久 `project-supervisor-game-chat` root source 启用。hydration 对 `Pending` 的容忍只适用于当前 source-aware lane 的三个零依赖首波任务,不再硬编码单个历史任务。页面进度、阶段记录和 final-reply 白名单统一使用六项任务与 `x/6`。 +- 首波决策:普通 GUI / CLI 的正式 16 节点 DAG 与 game-chat 首版 lane 都只把 `design-director / art-director / code-director` 作为首波 ready Agent。正式 DAG 的 `design-foundation` 等待策划与美术 Director,`code-prototype` 等待程序 Director 及数值、美术、音频三条底层产物链;game-chat 在策划与美术 Director 完成后启动 `art-asset-plan` 生成透明图集,`code-prototype` 必须同时等待三个 Director 与该图集任务,再串行执行静态检查和试玩。首波以外的非 repair 底层 Agent 只能由依赖就绪调度或上层明确返工合同按需激活。 +- Runtime 决策:game-chat 七任务 lane、平台美术、单轮收束和自动预览只由持久 `project-supervisor-game-chat` root source 启用。hydration 对 `Pending` 的容忍只适用于当前 source-aware lane 的三个零依赖首波任务。页面进度、阶段记录和 final-reply 白名单统一使用七项任务与 `x/7`。 - 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。 - 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。 ## 2026-08-03 game-chat 失败续跑与首版产物保护 - 事故事实:`game01` 的首个根 run 已交付并验证水晶下落方块原型,但父 run 在上下文压缩后因 token 上限失败;用户随后输入“继续”时,客户端创建了 task 仅为“继续”的新 game-chat root,完成合同重置 seed manifest,五分钟 fallback 再把该短语当主题整文件覆盖 `game/index.html`。这不是模型随机换题,而是 root 目标未继承、每新 run 无条件 reset 和 fallback 无条件 `file.write` 叠加形成的确定性缺陷。 -- 续跑决策:同一 Supervisor Session 最近失败根 run 后的严格继续短语创建 successor root,并继承前序原始任务、baseline revision / artifact 身份;真正新需求、跨 Session、前序正常完成或含具体新约束的输入继续创建独立新任务并重置本轮 manifest。successor 不复用旧网络请求、pending、action、Provider lifecycle 或 sidecar,只继承业务目标和已提交项目事实。 -- 覆盖决策:game-chat fallback 只允许初始化缺失/占位入口的首次落盘。非占位 `game/index.html` 必须保留,先读取并局部修复或复用现有静态/试玩门;纯继续目标未恢复时失败关闭。完成门新增 baseline 玩法连续性和 action-driven state 检查,generic Canvas 非空、三个按钮存在或静态 smoke 通过都不能单独证明任务没有换题。 -- 美术决策:`art-spec.png` 回归为规范图和下游派生参考,不能铺作完整场景,也不能裁剪成玩家/目标。首版允许依据规范使用程序化 Canvas 视觉;需要位图时使用独立透明素材。删除快车道中“必须把规范图作为主要背景/实体”的 prompt、fallback 与测试口径,并以“规范图不出现在运行画面、sequence 不随 RAF 空转”作为回归边界。 +- 续跑决策:同一 Supervisor Session、同一持久 source 的最近失败根 run 后,严格继续短语创建 successor root,并继承前序原始任务、baseline revision / artifact 身份;纯继续识别统一由一个精确函数负责,覆盖“继续 / 接着 / continue / go on”等无新约束短语。真正新需求、跨 Session、跨 GUI / CLI / game-chat source、前序正常完成或含具体新约束的输入继续创建独立新任务并重置本轮 manifest。successor 不复用旧网络请求、pending、action、Provider lifecycle 或 sidecar,只继承业务目标和已提交项目事实。 +- 覆盖决策:game-chat fallback 只允许初始化缺失/占位入口的首次落盘。非占位 `game/index.html` 必须保留,并由当前 `code-prototype` 先读取和实际 patch,取得本人 `mutationRevision` 后才能运行 `game.static_smoke` 与交付;只读 smoke 不得冒充续作。确定性 fallback 只允许已实现真实语义的显式玩法模板:俄罗斯方块模板必须具备 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败,收集模板只用于明确收集类目标,未知玩法失败关闭。纯继续目标未恢复时同样失败关闭。完成门新增 baseline 玩法连续性和 action-driven state 检查,generic Canvas 非空、三个按钮存在或静态 smoke 通过都不能单独证明任务没有换题。 +- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四,全部切片累计下载最多 `32 MiB`;主图、四张 canonical 切片与切片清单作为一个提交合同,主图安装或资产登记失败时必须恢复整组旧合同。`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 ## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index acc57dd6f..d2668b036 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -81,9 +81,11 @@ cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml real_ 修改 game-chat release flavor 后,至少执行壳配置门禁、AppSurface game-chat 定向测试、前端类型检查、AppData / 诊断日志 / release flavor 相关 Rust 定向测试、`npm run check:encoding` 和 `git diff --check`。打包 smoke 必须确认:安装信息和产物版本为 `0.1.1`;无参数启动直接进入且只能停留在 game-chat 页面;停止或断开 `api-server` 后本地工作台仍能打开;普通 dev / release 与 debug game-chat 仍走原认证入口;独立 AppData 生效。预览 smoke 应先让当前 run 成功验证 revision N,确认 Tauri registry 启动一个 server 且 iframe 自动出现;在 validate 后、start 取得锁前推进项目 revision,必须确认原子 `expectedRevision` 门禁拒绝启动且授权保留等待新证据;再验证 revision N+1,确认 server 进程和 loopback origin 不变、iframe 显示新版本且响应为 `no-store`。same-run steer 还要覆盖旧验证不消费新授权、旧异步 attempt 不清新 generation;Runner registry 单独 running、失败 / 相同 / 更低 revision 均不能触发用户预览或重复刷新,停止后顶部显示“预览未启动”。独立包退出时必须通过 `runner.shutdown_for_client_exit` 先进入 draining 再结束本 boot,保留 durable sidecar 供下次 reconciliation,不把中断任务写成 completed;Windows Runner 必须 `CREATE_SUSPENDED -> AssignProcessToJobObject -> ResumeThread`,分配或恢复失败时 kill + wait,客户端持有 kill-on-close Job 兜底,关闭主窗口后 Runner、MCP、command、ConPTY 及其后代都应消失。普通 dev / release 和 CLI 继续使用 `runner.shutdown_if_idle`。 -game-chat 迭代还必须确认以下行为:Supervisor ready 输出、`art-director / code-prototype / preview-readiness / preview-playtest` 四个专业 Agent 的 durable `final-reply`,以及 Rust 明确生成 `eventId + publicText` 的每条公开 Runtime 输出都作为独立 assistant 消息逐条固化在项目聊天;`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。消息通过顶层 `messageId` 幂等追加,事件 / 轮询 / hydration 重放不重复。前端禁止从原始 `summary / detail`、tool plan、Provider / Runner 元数据、命令输出或路径自行拼接持久消息;UI 把一个父 Run 统一显示为“本轮生成进度”,最新状态也不得暴露内部“第 N 轮”,完整 GUI / CLI 任务图可显示 `x/14`,首版快车道显示 `x/4`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口(包括 `agent.schedule_ready`、`task.list` 结果驱动的直接 `agent.delegate`)均不得暴露或启动 `publish-strategy` / `publish-package`。game-chat 必须配置可用的 External Editor API,由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,`code-prototype` 必须把它显著用于用户可见的主要背景、玩家和目标;未配置 API 或缺少任一环节都必须失败关闭。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。对应定向测试至少包括: +game-chat 迭代还必须确认以下行为:Supervisor ready 输出、`design-director / art-director / art-asset-plan / code-director / code-prototype / preview-readiness / preview-playtest` 七个专业 Agent 的 durable `final-reply`,以及 Rust 明确生成 `eventId + publicText` 的每条公开 Runtime 输出都作为独立 assistant 消息逐条固化在项目聊天。消息通过顶层 `messageId` 幂等追加,事件 / 轮询 / hydration 重放不重复。前端禁止从原始 `summary / detail`、tool plan、Provider / Runner 元数据、命令输出或路径自行拼接持久消息;UI 把一个父 Run 统一显示为“本轮生成进度”,最新状态也不得暴露内部“第 N 轮”,完整 GUI / CLI 任务图可显示 `x/14`,首版快车道显示 `x/7`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口均不得暴露或启动 `publish-strategy` / `publish-package`。game-chat 必须配置可用的 External Editor API,按 `art-spec.png -> icon-spritesheet -> art-spritesheet.png + iconImageSrcs 本地切片 -> code-prototype Canvas 使用四类切片` 推进;缺少任一环节都必须失败关闭。 -game-chat 首版快车道采用独立四阶段口径:只显示 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 的 `x/4`,不把完整 DAG 或内部 loop 计入分母。父 Run 与全部 child Run 共用 240 秒软预算和 300 秒累计硬上限;首版最多一次 Provider 规划 / 写入请求,软预算后只能运行确定性的本地 fallback、`game.static_smoke`、`preview.validate`,硬上限内未通过完成门必须失败,证据在上限后到齐也不得写 `single_round_converged`。live10 实测正式透明 `icon-spritesheet` 后处理超过 300 秒,因此 game-chat 改为一次平台 `images/generations` 生成并登记 `assets/art-spec.png`;fallback 只有该图片有效登记且真实存在时才允许生成,并必须把它显著绘制为主要背景、玩家和目标。未配置 External Editor API、图片生成失败、缺少 Canvas / manifest 登记、文件、HTML 引用或用户可见绘制时必须在 300 秒内失败关闭,绝不误报 completed。普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。对应定向测试至少包括: +game-chat 素材完整快车道采用七任务口径。父 Run 与全部 child Run 共用 4200 秒软预算和 4500 秒累计硬上限;先确定性生成并登记 `art-spec.png`,再由 `art-asset-plan` 通过专用 icon-spritesheet 路由生成透明 `art-spritesheet.png`,下载并持久化响应中的独立切片,最后才允许 code-prototype 写入或局部修复入口。软预算后只允许使用已登记图集、当前 resourceId 对应切片清单的确定性本地 fallback、`game.static_smoke` 和 `preview.validate`;不得退回普通生图、猜测 atlas 网格或纯代码核心画面。完成门要求活动 Canvas 分别绘制 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四类不同切片;整图 ``、CSS background、完整图集直绘、单个猜测裁切和路径诱饵均失败。对应定向测试至少包括: + +失败续跑还必须覆盖同 Session 同 source 继承、跨 Session / 跨 source 不继承、首次与连续 successor 的 effective task / contract / scheduler 一致性,以及中英文纯继续短语使用同一识别函数。非占位入口的新 `code-prototype` 必须先产生本人 mutation 再 smoke;连续只读 smoke 不得收束。占位 fallback 只允许显式支持的真实玩法模板,俄罗斯方块必须验证棋盘、下落、旋转、锁定和消行语义,未知玩法必须失败关闭。 game-chat GUI 恢复还要覆盖两类竞态:root Runtime 先终态、manifest 四阶段后终态时,必须等到四任务最终状态后仅持久化一条 `【Supervisor 阶段记录】`;页面初始 hydration 直接读到真实终态时也要补写缺失记录,但不得把 `idle` 当作完成。同时,GUI 启动的 `agent.resume` 自动扫描必须先做只读恢复工作预检:新项目或无 task / retry / handoff / finalization / pending / reconciliation 工作的已终态项目不弹确认,存在任何 durable recovery artifact 则仍必须命中 `agent.resume` policy。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a8d7d5e31..a5e75c1b6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4027,8 +4027,8 @@ - 现象:原根 run 已经写出并验证目标玩法,但父 Runtime 因预算、上下文或 Provider 失败;用户在同一项目输入“继续”后,页面标题变成“继续”,玩法被默认收集/点击模板替换,美术规范总览图被直接铺进游戏画面。 - 原因:终态失败 run 不能 steer,提交层因此创建 task 只有继续短语的新 root;每个新 autonomous 根合同又无条件 reset seed manifest;game-chat fallback 从当前 root task 取主题并直接 `file.write game/index.html`。同时快车道把 `icon-spec` 误当运行素材,要求整图背景和象限裁剪实体。通用 smoke/generic playtest 只验证结构与最小交互,无法发现玩法目标已经漂移。 -- 处理:严格继续意图必须在同一 Supervisor Session 内继承最近失败根 run 的原始目标和 baseline,但保持新的 run/Provider/sidecar 身份;真正新需求仍独立 reset。非占位入口禁止 fallback 整体覆盖,先保留并局部修复或验证。`art-spec.png` 只作规范参考,运行时位图必须来自独立派生素材;程序化首版不应为了“可见使用平台图片”把规范板塞进画面。 -- 验证:覆盖失败根任务“水晶俄罗斯方块”后输入“继续”、跨 Session、正常完成后新输入、带具体新需求、既有非占位入口、初始化占位、纯继续目标缺失、规范图不在运行 DOM/Canvas、真实动作前后 `sequence` 与 RAF 空转。浏览器验收必须同时比较 baseline 玩法关键文本/控件/状态和当前 revision,不能只看 Canvas 非空与三个固定按钮。 +- 处理:严格继续意图必须在同一 Supervisor Session、同一持久 source 内继承最近失败根 run 的原始目标和 baseline,但保持新的 run/Provider/sidecar 身份;纯继续词表只能有一个权威实现,中英文短语都走同一入口,真正新需求仍独立 reset。非占位入口禁止 fallback 整体覆盖,也不能反复运行只读 smoke;当前 `code-prototype` 必须先读取并实际 patch,取得本人 mutation 后才能验证和交付。占位 fallback 只支持具备真实语义的显式模板,俄罗斯方块必须实际实现棋盘、下落、旋转、锁定和消行,未知玩法失败关闭。`art-spec.png` 只作规范参考,核心运行时位图必须来自独立派生的透明 `art-spritesheet.png` 及其 `iconImageSrcs` 本地切片;切片清单绑定当前图集 resourceId,Canvas 分别使用玩家、目标、场景和反馈四类素材。不得猜测图集是 2×2 等分、把规范板塞进画面或以纯代码核心实体绕过派生素材。 +- 验证:覆盖失败根任务“水晶俄罗斯方块”后输入“继续”、连续 successor、跨 Session、跨 source、正常完成后新输入、带具体新需求、既有非占位入口先 patch 后 smoke、初始化占位的俄罗斯方块真实语义、未知玩法失败关闭、纯继续目标缺失、规范图不在运行 DOM/Canvas、真实动作前后 `sequence` 与 RAF 空转。浏览器验收必须同时比较 baseline 玩法关键文本/控件/状态和当前 revision,不能只看 Canvas 非空与三个固定按钮。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs`。 ## Tauri beforeDevCommand 失败不等于已启动客户端会自动退出(2026-08-03) @@ -4051,7 +4051,7 @@ - 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。 - 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。 -- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把精确请求体、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。重启时 `accepted` 只恢复 GET,`prepared`、响应丢失、`202` 缺 operationId、轮询超时和状态损坏都进入 `needs-reconciliation`,绝不自动 POST。game-chat 五分钟硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey`。`postprocess-failed-source-preserved` 进入不可自动重生的对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。 +- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把精确请求体、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。重启时 `accepted` 只恢复 GET,`prepared`、响应丢失、`202` 缺 operationId、轮询超时和状态损坏都进入 `needs-reconciliation`,绝不自动 POST。game-chat 的 4500 秒硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey`。`postprocess-failed-source-preserved` 进入不可自动重生的对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。 - 补充:不能把“accepted 分支里没有生成 POST”误当成 GET-only 恢复。若读取账本前仍重做项目/素材目录准备、输出路径预检或请求正文构造,恢复仍可能创建远端资源或在查询 operation 前失败。恢复必须直接使用 durable snapshot;清理必须最后删除 pending 身份锚点,活动 orphan 不得自动删除。完整恢复 future 还要在默认 Tokio worker 栈下验证,不能靠测试环境调大 `RUST_MIN_STACK` 掩盖栈溢出。 - 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时连 GET 也必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。提交只有契约明确的 `400 / 401 / 403` 可证明未入队并清理 prepared 账本;超时、冲突、限流、网关错误及其它意外状态均保留账本进入对账。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。 - 验证:覆盖“服务端已入队但提交响应丢失”后原键重试仍返回同一 operation、换 owner 不可见、查询最终只出现一份 completed result 和一次计费 / 写回;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5af71f34d..856dccd03 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -34,7 +34,7 @@ - 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。 - Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。 - Provider 故障展示:Provider retry 的“是否可重试”继续使用 `upstream-5xx` 等稳定类别判断,但 durable retry record 保留安全的精确 `upstream-` 身份。等待态必须从真实 record 显示 HTTP 状态、`nextAttempt/maxRetries` 与当前持久退避剩余秒数,例如“Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 8 秒后重试”;不得以动画或前端自增计时伪造 attempt。重试耗尽的 Runtime 私有错误只保存 `kind/httpStatus/fingerprint/chars/retryAttempt/maxRetries/retryState`,前端和持久 conversation 仅在字段顺序、范围、状态一致且无尾随正文时派生“上游服务返回 HTTP 503;自动重试已耗尽(3/3)”;其它错误使用固定安全摘要。Provider 响应正文、URL/query、凭据、本地绝对路径、fingerprint、字符数和 `[redacted ...]` 占位符均不得进入用户可见消息。 -- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待 `design-director / art-director / code-director / code-prototype / preview-readiness / preview-playtest` 六项首版任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。`art-asset-plan` 不属于五分钟首版阶段,不阻塞阶段记录。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/6` 提前归档,要暂存终态 Runtime 并在 manifest 刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 +- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待 `design-director / art-director / art-asset-plan / code-director / code-prototype / preview-readiness / preview-playtest` 七项首版任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/7` 提前归档,要暂存终态 Runtime 并在 manifest 刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 - 图片成果:当前 manifest 新增或恢复已登记的 PNG / JPEG / WebP 资源时,聊天消息流同步显示 Runtime-owned “Supervisor 成果图片”卡,最多展示最新 4 张并随 manifest 原位更新。图片必须通过现有 `read_local_project_image_preview` 读取,只允许当前授权项目中 `assets/` 下的已登记资源,继续执行 `file.read` auto 权限、真实格式、大小、尺寸、普通文件、祖先目录和项目根边界校验;前端只接受返回路径、媒体类型和 `data:` 前缀与请求完全一致的结果。缩略图点击后使用独立模态查看器,支持按钮与滚轮缩放、指针拖拽、双击 / 按钮复位、Esc / 按钮 / 遮罩关闭,移动端占满视口;不得在聊天卡下方追加展开区。图片卡不写入 conversation,不解析 assistant 文本中的任意 Markdown / 绝对路径,也不开放 `.agent` 验收截图读取。 - Run 接管:External Runner 模式下首次提交可能返回“旧 canonical state + 新 `acceptedRunId`”;页面必须以 `acceptedRunId` 作为本轮权威身份,在 state 尚未切换时显示“已投递,正在同步 Agent Runner”,并允许该 run 的 Tauri event 或轮询结果接管。不得把旧 idle state 当作本轮结果、过滤新 run 事件,自动预览授权也必须绑定 `acceptedRunId`。 - 运行容器:当前项目没有由 Tauri 客户端 `PreviewRegistry` 返回的有效 `running` 预览时,页面只渲染聊天,不显示游戏区域或占位文案,顶部运行状态必须明确显示“预览未启动”,不得再使用含义不明的“未启动”;预览运行后自动显示 iframe,桌面端按“游戏 2 / 聊天 1”分栏,移动端改为上下布局。预览停止、失败或切换项目后立即移除 iframe。运行容器继续只接受当前授权项目的 `http://127.0.0.1:*`,复用现有 CSP、iframe sandbox、autoplay、fullscreen 和 gamepad 约束;远程 URL、`file://`、手填地址或陈旧 manifest 状态均不得显示。 @@ -52,20 +52,20 @@ ## 2026-07-31 game-chat 输出、单轮预览与平台美术资源 -- 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;`design-director / art-director / code-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed` 的非空安全回复也分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签逐条追加到项目聊天;`art-asset-plan` 的回复不进入 game-chat 项目聊天。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。 +- 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;`design-director / art-director / art-asset-plan / code-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed` 的非空安全回复也分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签逐条追加到项目聊天。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。 - 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package`;`agent.schedule_ready` 必须按当前 Supervisor Run 的持久 source/profile 选择同一 source-aware scheduler,不能绕过该边界。`task.list` 对同一 root source 必须从任务行、readyTaskIds 和统计中排除两个发布节点,`agent.delegate` 也必须按 root binding 拒绝直接委派这两个节点,不能让 Provider 用“读取完整 DAG 后手工委派”恢复已裁掉的发布阶段。完成门满足且 collaboration、Provider batch、进程会话、视觉资源等非验证屏障全部清零后,Runtime 必须用确定性回复直接收束结构化计划并结束父 Run,不再请求下一次 Provider 工具计划。普通 GUI / CLI 仍执行完整发布 DAG。 -- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版只按六项任务显示 `x/6`(详见 2026-08-03 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 -- 平台美术资源:live10 实测正式透明 `icon-spritesheet` 的生成与后处理超过 `300` 秒,因此 game-chat 五分钟首版不运行 `art-asset-plan` 图集链路。配置 External Editor API 时,`art-director` 仍通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,但该文件只作为视觉规范与后续派生参考;`code-prototype` 不得把整张规范图铺作背景或裁成玩家、目标和场景实体,没有独立派生素材时使用与规范一致的自包含原创 Canvas 视觉。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG 和运行时可见使用硬门,不采用该快车道。 +- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版只按七项任务显示 `x/7`(详见 2026-08-03 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 +- 平台美术资源:game-chat 恢复正式视觉 DAG。`art-director` 先通过平台 `images/generations(kind=spec)` 生成并登记 `assets/art-spec.png`;`art-asset-plan` 再以该规范图的稳定 resourceId 调用 `icon-spritesheets/generations` 生成真实透明 `assets/art-spritesheet.png`,并把响应中的 `iconImageSrcs` 下载为本地独立切片,写入 `assets/art-spritesheet-slices/manifest.json`。`code-prototype` 必须等待图集与切片清单,并在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景与反馈四类切片;规范图只作 reference,纯代码核心画面、猜测图集等分网格、整图 `` / CSS 背景、完整图集直绘或只出现路径均不得完成。 - 验证:前端运行时模型定向测试、Rust completion/source/asset 合同测试、`cargo fmt --check`、`npm run check:encoding` 与 `git diff --check` 必须全部执行;Windows 文件锁竞态只可作为既有测试失败单独记录,不得将其改写为本次改动的通过证据。 -## 2026-08-01 game-chat 首版六任务快车道与美术硬门 +## 2026-08-01 game-chat 首版七任务素材完整快车道与美术硬门 -- 首版任务边界:game-chat 首版只展示 `design-director`、`art-director`、`code-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 六项任务,进度统一显示为 `x/6`;首波仅并行激活三个 Director,`code-prototype` 必须等待三者完成,后续验证再串行推进。`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。 -- 时间预算:从 game-chat 父 Run 接受用户请求开始,首版可玩版本使用 `240` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享从 root `bound_at` 计算的 `300` 秒绝对硬上限,不能把硬上限拆成每个 Agent 独立计时。整个 Runtime pass 必须受同一 `timeout_at` 约束,覆盖 Provider、图片生成、文件写入、静态检查、浏览器试玩和 final-reply 的在途等待;超时先强制持久化 `failed`,再清理恢复 sidecar 与进程会话。达到软预算后只允许进入确定性的本地兜底、静态 smoke 和浏览器试玩;达到硬上限仍未通过完成门必须失败关闭。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`,不得把超时伪装成 completed。 -- Provider 次数:首波 `design-director / code-director` 的规划请求与 `art-director` 的确定性平台生图并行;各 Director 只处理本组规划,不得提前激活底层 Agent。三者收束后,`code-prototype` 最多执行一次 Provider 首版写入请求;后续不再请求第二次代码 tool-plan、自动传输重试或无限 repair。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke` 与 `preview.validate`,以当前 revision 和真实浏览器证据决定是否可交付。 -- 可玩兜底:软预算或首版 Provider 无法及时完成时,可以生成完整、自包含、无远程运行依赖的中文 HTML 模板。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 只随真实输入、状态迁移或模拟状态变化推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,不得通过固定失败冒充试玩通过,也不得由纯渲染帧空转 `sequence`。兜底只允许写入缺失或精确初始化占位的 `game/index.html`;存在非占位入口、当前 run 已修改入口或当前 revision 已有可信验证证据时,必须保留既有玩法并转入静态检查、试玩或基于已读取原文的最小修复。 -- 平台图片:live10 已证明透明 `icon-spritesheet` 后处理无法稳定收进 `300` 秒。game-chat 配置 External Editor API 时,由 `art-director` 在首版预算内发起一次平台 `images/generations`,生成并登记 `assets/art-spec.png`;该文件是规范图,只用于约束色板、材质、形状与后续派生,不是 `game/index.html` 的运行时背景、角色、目标或图集。完成门不得要求规范图出现在 DOM、CSS 或活动 Canvas,也不得因其没有被运行时加载而阻断程序化首版。首版仍只在 `preview-playtest` 后单轮收束,不进入发布节点;普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG 与可见使用硬门。 -- 关联验收:快车道必须分别验证三 Director 首波并行、`code-prototype` 依赖三者、`x/6` 投影、六个专业 Agent 安全 final-reply 逐条入聊天且排除 `art-asset-plan`、240 / 300 秒累计预算、单次 `images/generations`、失败续跑目标继承、非占位入口禁止整文件覆盖、`art-spec.png` 仅作规范参考、程序化首版无需运行时加载规范图、action-driven `sequence`,以及当前 revision 的静态 smoke 与浏览器试玩;这些规则不改变普通 GUI / CLI 的完整任务图和透明图集硬门。 +- 首版任务边界:game-chat 首版只展示 `design-director`、`art-director`、`art-asset-plan`、`code-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 七项任务,进度统一显示为 `x/7`;首波并行激活三个 Director,`art-asset-plan` 依赖 `design-director + art-director`,`code-prototype` 同时等待三个 Director 与 `art-asset-plan`,后续验证串行推进。不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。 +- 时间预算:从 game-chat 父 Run 接受用户请求开始,素材完整首版使用 `4200` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享从 root `bound_at` 计算的 `4500` 秒绝对硬上限。该值来自现有两次串行生成各自最长 35 分钟的客户端等待合同,并为代码兜底、静态检查和双视口试玩保留 5 分钟。整个 Runtime pass 受同一 `timeout_at` 约束;硬上限内未通过完成门必须失败关闭,不得为了守时跳过图集、换普通生图或回退纯代码核心画面。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`。 +- Provider 次数:首波 `design-director / code-director` 的规划请求与 `art-director` 的确定性规范图生成并行;规范图和设计方向就绪后,Runtime 确定性派发 `art-asset-plan` 的 icon-spritesheet 生成。图集登记后 `code-prototype` 最多执行一次 Provider 首版写入请求;软预算耗尽时只允许生成真实加载并裁切图集的确定性本地兜底。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke` 与 `preview.validate`。 +- 可玩兜底:软预算或首版 Provider 无法及时完成时,只能为已显式实现真实语义的玩法生成完整、自包含、无远程运行依赖的中文 HTML 模板;未知玩法失败关闭,不能只替换标题后套用固定收集游戏。俄罗斯方块模板必须包含 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败;收集模板只匹配明确收集类目标。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 只随真实输入、状态迁移或模拟状态变化推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,不得通过固定失败冒充试玩通过,也不得由纯渲染帧空转 `sequence`。兜底只允许写入缺失或精确初始化占位的 `game/index.html`;存在非占位入口时,当前 `code-prototype` 必须读取并实际 patch,取得本人 `mutationRevision` 后再静态检查和试玩,不得反复用只读 smoke 冒充续作。 +- 平台图片:`art-spec.png` 只用于约束色板、材质、形状与后续派生,不是运行时背景、角色、目标或图集。`art-asset-plan` 必须以其稳定资源 ID 走专用 icon-spritesheet 路由,透明像素、generation route/kind、同画布归属和 reference resource ID 继续由 Runtime 验证。完整透明图集在编辑器合同中即使产生 `sliceWarning` 仍可登记,但 game-chat 不能据此猜测 atlas 坐标;图集 resourceId 缺失或空白时必须在任何本地写入前失败,切片数量也必须在正式图集登记前严格等于四,多或少都失败关闭。主图、四张 canonical 切片和切片清单必须共同提交,后续主图安装或登记失败时恢复旧切片合同,避免出现旧图集绑定新切片。全部切片下载累计最多 `32 MiB`。`postprocess-failed-source-preserved`、无真实 alpha、缺文件或缺登记同样失败关闭。首版只在 `preview-playtest` 后单轮收束,不进入发布节点。 +- 关联验收:快车道必须分别验证三 Director 首波并行、`art-asset-plan` 在代码前完成、`x/7` 投影、七个专业 Agent 安全 final-reply、4200 / 4500 秒累计预算、规范图到 icon-spritesheet 的真实引用、`iconImageSrcs` 本地持久化与资源 ID 绑定、失败续跑目标继承、非占位入口禁止整文件覆盖、纯代码核心画面、猜测单个 atlas 裁切与整图展示失败、四类独立切片可见使用通过、action-driven `sequence`,以及当前 revision 的静态 smoke 与浏览器试玩。 ## 2026-08-03 game-chat 开发态同源与持久输出修复 @@ -261,7 +261,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `project.restore`。Agent 可在 diff 或自检发现本轮修改走偏后请求恢复到指定 checkpoint;Runtime 复用 `project.restore` 权限策略和项目写锁,observation 只返回 checkpoint id、恢复文件数和删除文件数,不返回本机绝对路径。默认确认策略下不会静默回滚用户项目。 - 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。 - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 -- 2026-07-10 补充,2026-07-31 收紧,2026-08-03 增加发布窗口兼容与 durable 生成账本:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;正式新契约收到 HTTP `202` 后先原子追加 `operationId`,再按限制到 `250..=5000ms` 的 `pollAfterMs` 查询统一状态端点。重启时 `accepted` 账本只恢复 GET,`prepared` 代表提交结果未知并进入人工对账,绝不自动 POST。桌面客户端在滚动发布窗口内仍按 HTTP 状态兼容旧同步 `200` 完整结果;旧图集只在 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退 `objectKey`。生成 POST 使用独立三十五分钟等待预算,game-chat 仍受父 run 五分钟总截止约束。截止时普通本地动作按失败清理;若 `canvas.asset_generate` 已进入 executing,则结束本轮并关闭预览和客户端,但保留 pending action、provider batch、生成账本与 `needs-reconciliation`。旧 `200` 结果损坏、`202` 缺 operationId、响应丢失、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败均进入不可自动重生的对账边界。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。本地 manifest 只在生成完成后持久化 generation route、kind、服务端 taskId 与精确参考 resourceId。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。API Key 不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 +- 2026-07-10 补充,2026-07-31 收紧,2026-08-03 增加发布窗口兼容与 durable 生成账本:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;正式新契约收到 HTTP `202` 后先原子追加 `operationId`,再按限制到 `250..=5000ms` 的 `pollAfterMs` 查询统一状态端点。重启时 `accepted` 账本只恢复 GET,`prepared` 代表提交结果未知并进入人工对账,绝不自动 POST。桌面客户端在滚动发布窗口内仍按 HTTP 状态兼容旧同步 `200` 完整结果;旧图集只在 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退 `objectKey`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。截止时普通本地动作按失败清理;若 `canvas.asset_generate` 已进入 executing,则结束本轮并关闭预览和客户端,但保留 pending action、provider batch、生成账本与 `needs-reconciliation`。旧 `200` 结果损坏、`202` 缺 operationId、响应丢失、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败均进入不可自动重生的对账边界。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。本地 manifest 只在生成完成后持久化 generation route、kind、服务端 taskId 与精确参考 resourceId。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。API Key 不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 - 2026-08-03 durable 恢复补充:`accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源,再查询既有 operation;不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住 operation GET。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 - 2026-08-03 durable 恢复加固:生成账本还必须绑定归一化 base URL 与 API Key 哈希组成的配置指纹,当前 External Editor 服务或租户身份变更时禁止查询旧 operation。旧同步 `200` 结果只持久恢复必需的允许字段;绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。生成提交只有契约明确的 `400 / 401 / 403` 可视为入队前拒绝并清理 prepared 账本;其它非成功状态保留账本进入对账。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。独立恢复任务异常必须落盘 task queue、state、event 和 agent.db 对账阻断,公共记录不得复制未脱敏 panic payload。 - 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。 @@ -824,9 +824,9 @@ game-project/ - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。 - 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。 - 自然语言 interaction 的 `resume` 只代表“继续当前未完成 Runtime”。宿主在进入 interaction 前已确认当前 Session 没有可 steer、pending 或 running 的 Runtime 时,模型返回的自然语言 `resume` 必须规范化为 `execute`,基于会话历史新建 run;显式 `/resume` 仍只执行恢复扫描且无任务时不新建,避免“那就继续修复”被反复吞成空恢复。 -- 2026-08-03 失败续跑收口:同一 `project-supervisor` Session 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。 -- game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口、已有可信 smoke/playtest 证据或 baseline 摘要与当前文件一致时,后续 run 必须先保留既有玩法并走读取、局部修复或直接验证;禁止为了满足“五分钟首版”重新生成整份默认小游戏。纯继续意图未能恢复唯一原始目标时失败关闭,不输出以“继续”为标题的兜底产物。 -- `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 可根据其中的色板、材质和形状约束生成自包含程序化首版;若需要位图实体,必须使用独立派生的透明素材。完成门不得要求整张规范图可见铺满 Canvas、裁剪其象限充当实体或因规范图未在运行时加载而强制失败;隐藏引用、微小水印和诱饵路径仍不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。 +- 2026-08-03 失败续跑收口:同一 `project-supervisor` Session、同一持久 source 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”“continue”“go on”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;首次和连续 successor 的 effective task、合同 SHA、Runtime hydration 与 scheduler 必须一致。不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、跨 GUI / CLI / game-chat source、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。 +- game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。 +- `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。 - tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。 ## 2026-07-31 长耗时与恢复收口