From 964b8834666c528a9a137c819fdf49f403cca4a3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 1 Aug 2026 22:45:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=BA=E5=8C=96game-chat=E4=BA=94=E5=88=86?= =?UTF-8?q?=E9=92=9F=E9=A6=96=E7=89=88=E4=B8=8E=E5=B9=B3=E5=8F=B0=E7=BE=8E?= =?UTF-8?q?=E6=9C=AF=E7=A1=AC=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将game-chat首版固定为美术、代码、静态检查和试玩四阶段并单轮收束。 由平台生成并登记art-spec图片,强制用于主要背景、玩家和目标的可达Canvas绘制。 为全部在途动作增加从根Run计时的300秒绝对截止和确定性失败清理。 修复代码revision归属判断,防止美术变更跳过唯一Provider写入。 将四阶段公开输出和final-reply逐条固化到聊天并更新进度投影。 补齐图片解码、路径、隐藏与不可达绕过、超时竞态及前端回归测试和文档。 --- .../provider_request_builders.rs | 11 +- .../src-tauri/src/agent/runtime_driver.rs | 2 + .../runtime_driver/game_chat_fast_path.rs | 306 +++--- .../src/agent/runtime_driver/main_loop.rs | 210 +++++ .../main_loop_deadline_tests.rs | 162 ++++ .../agent/runtime_driver/main_loop_tests.rs | 295 +++++- .../src/agent/runtime_driver/task_start.rs | 11 +- .../runtime_protocol/autonomous_completion.rs | 886 +++++++++++++++++- .../autonomous_completion_contract_tests.rs | 247 ++++- .../src/agent/runtime_tools/task_ops.rs | 11 +- .../src-tauri/src/project/manifest.rs | 11 +- .../src-tauri/src/tests/project.rs | 11 + apps/ai-game-creator-shell/src/App.tsx | 45 +- .../SupervisorChatOnlyView.tsx | 2 + .../appSurface/project-development.suite.ts | 63 +- .../shared-memory/decision-log.md | 18 +- .../shared-memory/development-workflow.md | 6 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 20 +- 18 files changed, 2070 insertions(+), 247 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs 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 6e6c215cd..383a7c415 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 @@ -2,7 +2,7 @@ use super::*; const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止直接调用 respond_to_user,也禁止在 legacy response 中填写最终回复;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。"; -const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。当前任务的第一目标是在一次 Provider planning 内产出首个完整可玩版本:如果最新 observation 尚未显示 game/index.html 已由本 run 写入,本响应必须直接调用一次 file.write,把完整、自包含、可运行的 game/index.html 一次写完;禁止先调用读取、搜索、任务查询、委派、只更新计划或提交半成品。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。HTML 应直接包含 ../assets/art-spritesheet.png 的可选图片引用,并在图片不可用时使用 Canvas 绘制兜底,不能让缺图导致白屏。一次写入后不要继续扩写功能;Runtime 会在下一步自动执行静态自检并在通过后立即试玩。"; +const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。当前任务的第一目标是在一次 Provider planning 内产出首个完整可玩版本:如果最新 observation 尚未显示 game/index.html 已由本 run 写入,本响应必须直接调用一次 file.write,把完整、自包含、可运行的 game/index.html 一次写完;禁止先调用读取、搜索、任务查询、委派、只更新计划或提交半成品。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。已登记的平台视觉规范图 ../assets/art-spec.png 是首版必需资源,必须在主要游戏画面中显著可见使用:至少把规范图实际绘制为主要背景,并从规范图中绘制玩家角色和目标实体。禁止仅放置隐藏 img、透明或屏外元素、微小水印、不可见预加载或只在源码中引用;也禁止用纯几何图形冒充平台图片使用。若规范图无法加载,游戏必须明确失败关闭,不能退回纯 Canvas 几何兜底。一次写入后不要继续扩写功能;Runtime 会在下一步自动执行静态自检并在通过后立即试玩。"; fn game_chat_fast_path_prompt_for_root_source( agent_id: &str, @@ -518,8 +518,13 @@ mod tests { assert!(prompt.contains("一次 Provider planning")); assert!(prompt.contains("直接调用一次 file.write")); assert!(prompt.contains("禁止先调用读取、搜索、任务查询、委派")); - assert!(prompt.contains("../assets/art-spritesheet.png")); - assert!(prompt.contains("Canvas 绘制兜底")); + assert!(prompt.contains("../assets/art-spec.png")); + assert!(prompt.contains("平台视觉规范图")); + assert!(prompt.contains("主要背景")); + assert!(prompt.contains("玩家角色和目标实体")); + assert!(prompt.contains("禁止仅放置隐藏 img")); + assert!(prompt.contains("不能退回纯 Canvas 几何兜底")); + assert!(!prompt.contains("art-spritesheet.png")); 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.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 7a113a513..bb36a99d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -211,6 +211,8 @@ mod interaction; mod lifecycle_control; mod main_loop; #[cfg(test)] +mod main_loop_deadline_tests; +#[cfg(test)] mod main_loop_tests; mod pending_execution; mod pending_recovery; 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 169a54cb3..8a8f51cdc 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 @@ -89,46 +89,54 @@ fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRunt } } -pub(crate) fn game_chat_fast_path_fallback_write_plan(task: &str) -> AgentRuntimeToolPlan { - game_chat_fast_path_action( +fn game_chat_fast_path_fallback_write_plan_for_root( + root: &Path, + task: &str, +) -> Result { + if !game_chat_fast_path_has_platform_art_asset(root) { + return Err( + "game-chat 首版缺少已登记且可验证的平台视觉规范图,拒绝退回纯几何 Canvas".to_string(), + ); + } + Ok(game_chat_fast_path_action( "file.write", serde_json::json!({ "path": AGENT_RUNTIME_GAME_INDEX_PATH, "content": render_game_chat_fast_path_html(task), }), - ) -} - -fn game_chat_fast_path_fallback_write_plan_for_root( - root: &Path, - task: &str, -) -> AgentRuntimeToolPlan { - let has_platform_art = game_chat_fast_path_has_platform_art_asset(root); - game_chat_fast_path_action( - "file.write", - serde_json::json!({ - "path": AGENT_RUNTIME_GAME_INDEX_PATH, - "content": render_game_chat_fast_path_html_with_platform_art(task, has_platform_art), - }), - ) + )) } fn game_chat_fast_path_has_platform_art_asset(root: &Path) -> bool { let Ok(manifest) = read_manifest_for_project(root) else { return false; }; - manifest.assets.iter().any(|asset| { - asset.kind == "art-spritesheet" - && asset.local_path == "assets/art-spritesheet.png" - && root.join("assets/art-spritesheet.png").is_file() + validate_manifest_required_visual_asset(root, &manifest, "art-director").is_ok() +} + +fn game_chat_fast_path_has_visual_asset(root: &Path, task_id: &str) -> bool { + read_manifest_for_project(root).is_ok_and(|manifest| { + validate_manifest_required_visual_asset(root, &manifest, task_id).is_ok() }) } -pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( +fn game_chat_fast_path_canvas_generation_failed(runtime: &AgentRuntimeState) -> bool { + runtime.observations.iter().rev().any(|observation| { + [ + "canvas.asset_generate:failed", + "canvas.asset_generate:blocked", + "canvas.asset_generate:rejected", + "canvas.asset_generate:needs-reconciliation", + ] + .iter() + .any(|prefix| observation.starts_with(prefix)) + }) +} + +fn game_chat_fast_path_root_task( root: &Path, budget: &GameChatFastPathBudget, - _fallback_task: &str, -) -> Result { +) -> Result { let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( root, &budget.root_agent_id, @@ -139,9 +147,45 @@ pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( if root_task.trim().is_empty() { return Err("game-chat 首版快车道 root 任务为空".to_string()); } - Ok(game_chat_fast_path_fallback_write_plan_for_root( - root, &root_task, - )) + Ok(root_task) +} + +fn game_chat_fast_path_canvas_asset_plan(task_id: &str, root_task: &str) -> AgentRuntimeToolPlan { + let theme = safe_theme_summary(root_task); + let (prompt, output_path, aspect_ratio, image_size, asset_kind, asset_label) = match task_id { + "art-director" => ( + format!( + "为原创小游戏“{theme}”生成统一视觉规范图:清晰展示玩家主体、目标物、场景地块、障碍、UI 图标、状态反馈、统一色板和材质规则;同一张图必须可直接作为首版主要背景、玩家和目标的可见绘制来源,不得使用现有知名游戏角色或标识。" + ), + AGENT_RUNTIME_ART_SPEC_PATH, + "1:1", + "1K", + "icon-spec", + "游戏统一视觉规范图", + ), + _ => unreachable!("only deterministic game-chat art tasks use this helper"), + }; + game_chat_fast_path_action( + "canvas.asset_generate", + serde_json::json!({ + "prompt": prompt, + "outputPath": output_path, + "aspectRatio": aspect_ratio, + "imageSize": image_size, + "assetKind": asset_kind, + "assetLabel": asset_label, + "replaceExisting": false, + }), + ) +} + +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)?; + game_chat_fast_path_fallback_write_plan_for_root(root, &root_task) } fn game_chat_fast_path_verified_delivery_plan( @@ -201,6 +245,31 @@ pub(crate) fn game_chat_fast_path_plan_at( return Ok(None); }; match runtime.agent_id.as_str() { + "art-director" => { + if game_chat_fast_path_has_visual_asset(root, "art-director") { + return Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "统一视觉规范图已生成并登记。", + ))); + } + if !editor_api_key_is_configured() { + return Err( + "game-chat 首版必须配置 External Editor API Key 才能生成平台美术资源" + .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-director", + &root_task, + ))) + } "preview-readiness" => { if game_chat_fast_path_current_revision_is_verified(root, runtime)? { Ok(Some(game_chat_fast_path_verified_delivery_plan( @@ -240,12 +309,12 @@ pub(crate) fn game_chat_fast_path_plan_at( &runtime.agent_id, &runtime.run_id, )?; - let current_revision_changed = revision.revision > budget.baseline_revision; - let current_revision_verified = current_revision_changed + let owns_current_mutation = gate.mutation_revision == Some(revision.revision); + let current_revision_verified = owns_current_mutation && gate.verified_revision == Some(revision.revision) && gate.last_verification_status.as_deref() == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED); - let current_revision_failed = current_revision_changed + let current_revision_failed = owns_current_mutation && gate.last_verification_status.as_deref() == Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED); @@ -255,7 +324,7 @@ pub(crate) fn game_chat_fast_path_plan_at( "首个可玩版本代码已生成并通过静态自检。", ))); } - if current_revision_changed && !current_revision_failed { + if owns_current_mutation && !current_revision_failed { return Ok(Some(game_chat_fast_path_action( "command.run_limited", serde_json::json!({ "commandId": "game.static_smoke" }), @@ -280,19 +349,9 @@ pub(crate) fn game_chat_fast_path_plan_at( /// The summary is escaped before it is inserted into HTML. It is only placed in a data /// attribute and text nodes; it is never interpolated into JavaScript source. pub(crate) fn render_game_chat_fast_path_html(prompt: &str) -> String { - render_game_chat_fast_path_html_with_platform_art(prompt, false) -} - -fn render_game_chat_fast_path_html_with_platform_art( - prompt: &str, - has_platform_art: bool, -) -> String { let theme = html_escape(&safe_theme_summary(prompt)); - let platform_art = if has_platform_art { - r#""# - } else { - r#""# - }; + let platform_art = + r#"平台生成的统一视觉规范图"#; FALLBACK_GAME_HTML .replace(FALLBACK_THEME_MARKER, &theme) .replace(FALLBACK_PLATFORM_ART_MARKER, platform_art) @@ -368,7 +427,7 @@ const FALLBACK_GAME_HTML: &str = r###" button:hover, button:focus-visible { border-color: #9ed0ff; background: #214a76; outline: none; } .state-line { display: flex; justify-content: space-between; gap: 12px; padding: 12px 14px; color: #cce1fa; background: #102541; } .state-line strong { color: #8fe2c4; } - #platform-art { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } + #platform-art { position: absolute; z-index: 2; top: 18px; right: 18px; width: min(24%, 180px); aspect-ratio: 1; object-fit: contain; border: 2px solid #9ed0ff; border-radius: 16px; background: #07101dcc; box-shadow: 0 12px 28px #02081499; opacity: .88; pointer-events: none; } @media (max-width: 560px) { header { display: block; } .hud { justify-content: flex-start; margin-top: 10px; } .controls { grid-template-columns: 1fr; } } @@ -409,6 +468,14 @@ const FALLBACK_GAME_HTML: &str = r###" let playerX = 180; let targetX = 690; let pulse = 0; + let artLoadFailed = false; + + platformArt.addEventListener('error', () => { + artLoadFailed = true; + state.phase = 'lost'; + objectiveLabel.textContent = '平台美术资源加载失败,首版已停止'; + publish(); + }); function publish() { state.sequence += 1; @@ -426,6 +493,7 @@ const FALLBACK_GAME_HTML: &str = r###" } function startGame() { + if (artLoadFailed || !platformArt.complete || platformArt.naturalWidth === 0) return; if (state.phase === 'ready') setPhase('playing'); canvas.focus(); } @@ -474,22 +542,27 @@ const FALLBACK_GAME_HTML: &str = r###" context.strokeStyle = '#2a5f85'; context.lineWidth = 3; context.strokeRect(20, 20, canvas.width - 40, canvas.height - 132); - context.fillStyle = '#8fe2c4'; - context.beginPath(); - context.arc(targetX, 250, 28 + Math.sin(pulse) * 4, 0, Math.PI * 2); - context.fill(); - context.fillStyle = '#ffcf66'; - context.fillRect(playerX, canvas.height - 145, 58, 53); + const artReady = platformArt.complete && platformArt.naturalWidth > 0 && !artLoadFailed; + if (artReady) { + const sourceWidth = platformArt.naturalWidth; + const sourceHeight = platformArt.naturalHeight; + const halfWidth = Math.max(1, Math.floor(sourceWidth / 2)); + const halfHeight = Math.max(1, Math.floor(sourceHeight / 2)); + context.globalAlpha = 0.34; + context.drawImage(platformArt, 0, 0, canvas.width, canvas.height); + context.globalAlpha = 1; + context.drawImage(platformArt, 0, 0, halfWidth, halfHeight, playerX - 18, canvas.height - 190, 116, 104); + context.drawImage(platformArt, halfWidth, 0, sourceWidth - halfWidth, halfHeight, targetX - 52, 192 + Math.sin(pulse) * 5, 112, 112); + } else if (!artLoadFailed) { + context.fillStyle = '#eaf4ff'; + context.font = 'bold 22px system-ui, sans-serif'; + context.fillText('正在载入平台美术资源…', 38, 96); + } context.fillStyle = '#eaf4ff'; context.font = 'bold 22px system-ui, sans-serif'; context.fillText(state.phase === 'playing' ? '继续前进' : state.phase === 'won' ? '挑战成功' : state.phase === 'lost' ? '挑战失败' : '点击开始', 38, 58); context.font = '16px system-ui, sans-serif'; context.fillText(`目标能量 · ${state.score}/8`, 38, canvas.height - 42); - if (platformArt.complete && platformArt.naturalWidth > 0) { - context.globalAlpha = 0.18; - context.drawImage(platformArt, canvas.width - 180, 34, 120, 120); - context.globalAlpha = 1; - } requestAnimationFrame(draw); publish(); } @@ -506,6 +579,36 @@ mod tests { use super::*; use crate::agent::{validate_game_html_smoke, validate_playable_game_html}; + fn write_visual_png(path: &Path, alpha: u8) { + image::RgbaImage::from_pixel(4, 4, image::Rgba([80, 160, 220, alpha])) + .save(path) + .expect("write valid PNG fixture"); + } + + fn register_platform_art_spec(root: &Path) { + write_visual_png(&root.join(AGENT_RUNTIME_ART_SPEC_PATH), u8::MAX); + register_local_asset_at( + root, + AGENT_RUNTIME_ART_SPEC_PATH, + "icon-spec", + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("platform-art-canvas".to_string()), + resource_id: Some("platform-art-spec-resource".to_string()), + asset_object_id: Some("platform-art-spec-object".to_string()), + task_id: Some("art-director".to_string()), + prompt: None, + model: None, + generation_route: Some("/api/external/v1/editor/images/generations".to_string()), + generation_kind: Some("spec".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register platform art spec fixture"); + } + #[test] fn budgets_leave_a_soft_and_hard_window() { assert_eq!(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, 240); @@ -532,56 +635,43 @@ mod tests { ] { assert!(html.contains(marker), "missing fallback marker: {marker}"); } - assert!(!html.contains("../assets/art-spritesheet.png")); - assert!(!html.contains(" src=\"../assets/art-spritesheet.png\"")); + assert!(html.contains("src=\"../assets/art-spec.png\"")); + assert!(!html.contains("art-spritesheet.png")); + assert!(html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)")); + assert!(html.contains("playerX - 18")); + assert!(html.contains("targetX - 52")); + assert!(!html.contains("opacity: 0")); + assert!(!html.contains("width: 1px")); } #[test] - fn fallback_html_loads_registered_platform_art_only_when_file_exists() { + fn fallback_html_requires_registered_platform_art_and_uses_it_prominently() { let temporary = tempfile::tempdir().expect("temporary project"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "game-chat-platform-art", "platform art") .expect("initialize project"); - let without_asset = game_chat_fast_path_fallback_write_plan_for_root( + assert!(game_chat_fast_path_fallback_write_plan_for_root( &root, - "没有美术资源时使用 Canvas fallback", - ); - let without_html = without_asset.actions[0].input["content"] - .as_str() - .expect("fallback html without asset"); - assert!(!without_html.contains(" src=\"../assets/art-spritesheet.png\"")); - - fs::write(root.join("assets/art-spritesheet.png"), b"fixture") - .expect("write platform art fixture"); - register_local_asset_at( - &root, - "assets/art-spritesheet.png", - "art-spritesheet", - "image/png", - "canvas", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some("platform-art-canvas".to_string()), - resource_id: Some("platform-art-resource".to_string()), - asset_object_id: Some("platform-art-object".to_string()), - task_id: Some("art-asset-plan".to_string()), - prompt: None, - model: None, - generation_route: None, - generation_kind: Some("art-spritesheet".to_string()), - reference_resource_ids: Vec::new(), - }, + "没有美术资源时必须失败关闭", ) - .expect("register platform art fixture"); + .is_err()); + + register_platform_art_spec(&root); let with_asset = - game_chat_fast_path_fallback_write_plan_for_root(&root, "有美术资源时加载平台图集"); + game_chat_fast_path_fallback_write_plan_for_root(&root, "有美术资源时加载平台规范图") + .expect("render art-backed fallback"); let with_html = with_asset.actions[0].input["content"] .as_str() .expect("fallback html with asset"); - assert!(with_html.contains("src=\"../assets/art-spritesheet.png\"")); - assert!(with_html.contains("drawImage(platformArt")); + assert!(with_html.contains("src=\"../assets/art-spec.png\"")); + assert!(!with_html.contains("art-spritesheet.png")); + assert!( + with_html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)") + ); + assert!(with_html.contains("playerX - 18")); + assert!(with_html.contains("targetX - 52")); } #[test] @@ -594,36 +684,15 @@ mod tests { "platform art missing", ) .expect("initialize project"); - let asset_path = root.join("assets/art-spritesheet.png"); - fs::write(&asset_path, b"fixture").expect("write temporary asset"); - register_local_asset_at( - &root, - "assets/art-spritesheet.png", - "art-spritesheet", - "image/png", - "canvas", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some("platform-art-canvas".to_string()), - resource_id: Some("platform-art-resource".to_string()), - asset_object_id: None, - task_id: Some("art-asset-plan".to_string()), - prompt: None, - model: None, - generation_route: None, - generation_kind: Some("art-spritesheet".to_string()), - reference_resource_ids: Vec::new(), - }, - ) - .expect("register platform art fixture"); + register_platform_art_spec(&root); + let asset_path = root.join(AGENT_RUNTIME_ART_SPEC_PATH); fs::remove_file(asset_path).expect("remove platform art fixture"); - let plan = - game_chat_fast_path_fallback_write_plan_for_root(&root, "缺图时仍使用 Canvas fallback"); - let html = plan.actions[0].input["content"] - .as_str() - .expect("fallback html"); - assert!(!html.contains(" src=\"../assets/art-spritesheet.png\"")); + assert!(game_chat_fast_path_fallback_write_plan_for_root( + &root, + "缺图时拒绝 Canvas fallback", + ) + .is_err()); } #[test] @@ -680,6 +749,7 @@ mod tests { baseline_revision: 0, elapsed_seconds: 240, }; + register_platform_art_spec(&root); let plan = game_chat_fast_path_fallback_write_plan_for_budget_at( &root, 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 61753abf3..8f4a1992b 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 @@ -255,12 +255,222 @@ const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET: &str = "loop-budget-exhauste const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed"; +pub(super) fn game_chat_absolute_deadline_from_bound_at( + now_instant: tokio::time::Instant, + now_unix: u64, + root_bound_at: u64, +) -> tokio::time::Instant { + let deadline_unix = root_bound_at.saturating_add(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS); + now_instant + std::time::Duration::from_secs(deadline_unix.saturating_sub(now_unix)) +} + +fn game_chat_absolute_deadline_at( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result, String> { + if runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(None); + } + let now_unix = unix_timestamp(); + let Some(budget) = + game_chat_fast_path_budget_at(root, &runtime.agent_id, &runtime.run_id, now_unix)? + else { + return Ok(None); + }; + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &budget.root_agent_id, + &budget.root_run_id, + )? + .ok_or_else(|| "game-chat 绝对硬截止缺少 root Run Profile 绑定".to_string())?; + if root_binding.agent_id != budget.root_agent_id + || root_binding.run_id != budget.root_run_id + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + || root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + { + return Err("game-chat 绝对硬截止 root Run Profile 绑定身份不一致".to_string()); + } + Ok(Some(game_chat_absolute_deadline_from_bound_at( + tokio::time::Instant::now(), + now_unix, + root_binding.bound_at, + ))) +} + +pub(super) async fn await_game_chat_absolute_deadline_at( + deadline: tokio::time::Instant, + future: F, +) -> Result +where + F: std::future::Future, +{ + tokio::time::timeout_at(deadline, future).await +} + +fn latest_game_chat_deadline_runtime_at( + root: &Path, + fallback: AgentRuntimeState, +) -> AgentRuntimeState { + fs::read_to_string(game_creator_agent_runtime_session_path( + root, + &fallback.agent_id, + )) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .filter(|runtime| { + runtime.agent_id == fallback.agent_id + && runtime.session_id == fallback.session_id + && runtime.run_id == fallback.run_id + }) + .unwrap_or(fallback) +} + +pub(super) fn finish_game_chat_absolute_deadline_timeout_at( + root: &Path, + agent_id: &str, + session_id: &str, + fallback: AgentRuntimeState, +) -> AgentBackgroundTaskOutcome { + let runtime = latest_game_chat_deadline_runtime_at(root, fallback); + let pending_action = read_game_creator_agent_runtime_pending_tool_action( + root, + &runtime.agent_id, + &runtime.run_id, + ) + .ok(); + let error = format!( + "{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾", + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + ); + // The generic background failure helper deliberately preserves a durable + // needs-reconciliation state. A hard deadline is different: no action may + // remain recoverable after the root budget expires. Persist the terminal + // failure first, explicitly bypassing that guard, and only then remove the + // recovery material. + let terminal_failure_error = fail_game_creator_agent_runtime_turn_at( + root, + runtime.clone(), + &redact_agent_runtime_error(root, &error, 500), + ) + .err(); + let terminal_runtime = latest_game_chat_deadline_runtime_at(root, runtime); + let (_, preview_stopped) = game_creator_preview_registry().stop_for_project(Some(root)); + let process_cleanup_error = terminate_process_sessions_for_run_at( + root, + &terminal_runtime.agent_id, + &terminal_runtime.run_id, + ) + .err(); + let mut cleanup_errors = Vec::new(); + for result in [ + remove_game_creator_agent_runtime_pending_tool_action( + root, + &terminal_runtime.agent_id, + &terminal_runtime.run_id, + ), + remove_game_creator_agent_runtime_provider_action_batch( + root, + &terminal_runtime.agent_id, + &terminal_runtime.run_id, + ), + remove_game_creator_agent_runtime_confirmations( + root, + &terminal_runtime.agent_id, + &terminal_runtime.run_id, + ), + remove_game_creator_agent_runtime_provider_recovery_at( + root, + &terminal_runtime.agent_id, + &terminal_runtime.run_id, + ), + ] { + if let Err(error) = result { + cleanup_errors.push(sanitize_agent_runtime_text(&error, 160)); + } + } + if let Some(error) = process_cleanup_error { + cleanup_errors.push(sanitize_agent_runtime_text(&error, 160)); + } + if let Some(error) = terminal_failure_error { + cleanup_errors.push(sanitize_agent_runtime_text(&error, 160)); + } + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.game_chat.absolute_hard_deadline", + "agentId": terminal_runtime.agent_id, + "taskId": terminal_runtime.task_id, + "sessionId": terminal_runtime.session_id, + "runId": terminal_runtime.run_id, + "hardBudgetSeconds": GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS, + "pendingActionId": pending_action.as_ref().map(|pending| pending.action_id.as_str()), + "pendingTool": pending_action.as_ref().map(|pending| pending.action.tool.as_str()), + "previewStopped": preview_stopped, + "cleanupErrorCount": cleanup_errors.len(), + }), + ); + let _ = append_local_conversation_message_for_session_at( + root, + Some(agent_id), + Some(session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error), + agent_id: None, + }, + ); + AgentBackgroundTaskOutcome::Finished +} + pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_context( root: PathBuf, agent_id: String, task: String, state: AgentRuntimeState, continuation: AgentRuntimeContinuationContext, +) -> AgentBackgroundTaskOutcome { + let session_id = state.session_id.clone(); + let deadline_runtime = state.clone(); + let deadline = match game_chat_absolute_deadline_at(&root, &state) { + Ok(deadline) => deadline, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + state, + &format!("读取 game-chat 绝对硬截止失败:{error}"), + ); + } + }; + let pass = run_game_creator_agent_background_task_pass_without_deadline( + root.clone(), + agent_id.clone(), + task, + state, + continuation, + ); + let Some(deadline) = deadline else { + return pass.await; + }; + match await_game_chat_absolute_deadline_at(deadline, pass).await { + Ok(outcome) => outcome, + Err(_) => finish_game_chat_absolute_deadline_timeout_at( + &root, + &agent_id, + &session_id, + deadline_runtime, + ), + } +} + +async fn run_game_creator_agent_background_task_pass_without_deadline( + root: PathBuf, + agent_id: String, + task: String, + state: AgentRuntimeState, + continuation: AgentRuntimeContinuationContext, ) -> AgentBackgroundTaskOutcome { let session_id = state.session_id.clone(); let mut runtime = state; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs new file mode 100644 index 000000000..9c584173a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs @@ -0,0 +1,162 @@ +use super::main_loop::{ + await_game_chat_absolute_deadline_at, finish_game_chat_absolute_deadline_timeout_at, + game_chat_absolute_deadline_from_bound_at, +}; +use super::*; + +#[test] +fn game_chat_absolute_deadline_is_root_bound_at_plus_hard_budget() { + let now_instant = tokio::time::Instant::now(); + let bound_at = 10_000; + let now_unix = bound_at + 17; + + let deadline = game_chat_absolute_deadline_from_bound_at(now_instant, now_unix, bound_at); + + assert_eq!( + deadline.duration_since(now_instant), + std::time::Duration::from_secs(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS - 17) + ); +} + +#[test] +fn game_chat_absolute_deadline_is_immediate_once_root_budget_is_exhausted() { + let now_instant = tokio::time::Instant::now(); + let bound_at = 20_000; + + let deadline = game_chat_absolute_deadline_from_bound_at( + now_instant, + bound_at + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + 1, + bound_at, + ); + + assert_eq!(deadline, now_instant); +} + +#[tokio::test] +async fn game_chat_absolute_deadline_stops_a_never_resolving_in_flight_action() { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(20); + let started = std::time::Instant::now(); + + let result = await_game_chat_absolute_deadline_at( + deadline, + std::future::pending::(), + ) + .await; + + assert!( + result.is_err(), + "never-resolving action must hit the hard deadline" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "deadline test must not hang" + ); +} + +#[tokio::test] +async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + + let result = await_game_chat_absolute_deadline_at(deadline, async { "completed" }).await; + + assert_eq!(result.expect("in-flight action completes"), "completed"); +} + +#[test] +fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_cleanup() { + let root = std::env::temp_dir().join(format!( + "genarrative-game-chat-deadline-reconciliation-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试") + .expect("project init"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "执行可能悬挂的首版写入", + "game-chat-deadline-reconciliation-run", + "agent-ready-task-scheduler", + "正在执行首版写入", + vec!["执行首版写入".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("模拟截止时仍在途的写入".to_string()), + input: serde_json::json!({ + "path": "game/index.html", + "content": "deadline" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "准备首版写入".to_string(), + plan_update: None, + plan: vec!["写入首版".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let project_revision = + read_game_creator_agent_runtime_project_revision(&root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(&root) + .expect("repository context") + .fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( + &root, + &runtime, + &runtime.current_task, + &plan, + &[], + &project_revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ) + .expect("build pending action"); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write pending action"); + runtime.pending_tool_action = Some(pending.summary()); + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "等待人工核对在途动作".to_string(); + runtime.waiting_on = "开发者核对副作用".to_string(); + runtime.next_step = "核对后恢复".to_string(); + runtime.error = Some("模拟 needs-reconciliation".to_string()); + append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task"); + write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state"); + + let outcome = finish_game_chat_absolute_deadline_timeout_at( + &root, + &runtime.agent_id, + &runtime.session_id, + runtime.clone(), + ); + + assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished)); + let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) + .expect("read terminal runtime") + .state; + assert_eq!(terminal.run_id, runtime.run_id); + assert_eq!(terminal.status, "failed"); + assert_eq!(terminal.phase, "failed"); + assert!(terminal.pending_tool_action.is_none()); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + &root, + &runtime.agent_id, + &runtime.run_id + )); + assert!(!game_creator_agent_runtime_provider_action_batch_exists( + &root, + &runtime.agent_id, + &runtime.run_id + )); + + fs::remove_dir_all(root).ok(); +} 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 f17d7ead6..6bc7d08ec 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 @@ -25,6 +25,86 @@ fn register_autonomous_recovery_visual_fixture(root: &Path, local_path: &str, ki .expect("register autonomous recovery visual fixture"); } +fn register_game_chat_art_spec_fixture(root: &Path) { + image::RgbaImage::from_pixel(4, 4, image::Rgba([90, 140, 220, u8::MAX])) + .save(root.join(AGENT_RUNTIME_ART_SPEC_PATH)) + .expect("write game-chat art spec PNG"); + register_local_asset_at( + root, + AGENT_RUNTIME_ART_SPEC_PATH, + "icon-spec", + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("game-chat-canvas".to_string()), + resource_id: Some("game-chat-art-spec-resource".to_string()), + asset_object_id: Some("game-chat-art-spec-object".to_string()), + task_id: Some("art-director".to_string()), + prompt: None, + model: None, + generation_route: Some("/api/external/v1/editor/images/generations".to_string()), + generation_kind: Some("spec".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register game-chat art spec"); +} + +fn queue_game_chat_fast_path_child( + root: &Path, + root_run_id: &str, + root_task: &str, + child_id: &str, +) -> (AgentRuntimeState, AgentRuntimeState) { + let root_session = resolve_agent_conversation_session_id_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve game-chat root session"); + let root_record = append_unique_game_creator_agent_runtime_pending_task( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_session, + root_task, + root_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue game-chat root"); + let root_state = agent_runtime_state_from_task_record(&root_record); + let manifest = read_manifest_for_project(root).expect("read game-chat manifest"); + let task = manifest + .tasks + .iter() + .find(|task| task.id == child_id) + .unwrap_or_else(|| panic!("missing game-chat child task {child_id}")); + let child_session = resolve_agent_conversation_session_id_at(root, child_id, None, true) + .expect("resolve game-chat child session"); + let child_record = append_unique_game_creator_agent_runtime_pending_task( + root, + child_id, + &child_session, + &render_autonomous_manifest_ready_task_background_prompt(task), + &autonomous_manifest_ready_task_run_id(root_run_id, child_id), + "agent-ready-task-scheduler", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(root_state.agent_id.clone()), + parent_run_id: Some(root_state.run_id.clone()), + delegation_id: None, + }), + ) + .expect("queue game-chat fast-path child"); + ( + root_state, + agent_runtime_state_from_task_record(&child_record), + ) +} + #[test] fn autonomous_parent_keeps_planning_before_scheduling_registered_legacy_derived_visuals() { let root = std::env::temp_dir().join(format!( @@ -65,7 +145,7 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState write_local_project_file_at( root, AGENT_RUNTIME_GAME_INDEX_PATH, - "可玩塔防", + &render_game_chat_fast_path_html("可玩塔防"), ) .expect("write autonomous game index"); for (path, content) in [ @@ -84,6 +164,7 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState } revision }; + register_game_chat_art_spec_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"); @@ -263,6 +344,214 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf )); } +#[test] +fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planning() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"game-chat-fast-art-key"}}"#.to_string(), + ); + let temporary = tempfile::tempdir().expect("create game-chat art root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-art-director", "星空飞船收集能量") + .expect("init game-chat art project"); + let (root_state, child_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-art-director-root", + "制作星空飞船收集能量小游戏", + "art-director", + ); + 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-director plan") + .expect("art-director fast path 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"], + AGENT_RUNTIME_ART_SPEC_PATH + ); + assert_eq!(plan.actions[0].input["assetKind"], "icon-spec"); + assert_eq!(plan.actions[0].input["aspectRatio"], "1:1"); + assert_eq!(plan.actions[0].input["imageSize"], "1K"); + assert_eq!(plan.actions[0].input["replaceExisting"], false); + let serialized = serde_json::to_string(&plan).expect("serialize deterministic art plan"); + assert!(!serialized.contains("art-spritesheet")); + assert!(!serialized.contains("icon-spritesheets/generations")); + assert!(!root.join("assets/manifest.art.json").exists()); +} + +#[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()); + let temporary = tempfile::tempdir().expect("create unconfigured game-chat art root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-art-unconfigured", "太空躲避") + .expect("init unconfigured game-chat art project"); + let (root_state, child_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-art-unconfigured-root", + "制作太空躲避小游戏", + "art-director", + ); + 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 error = game_chat_fast_path_plan_at( + &root, + &child_state, + &child_state.current_task, + binding.bound_at, + ) + .expect_err("unconfigured game-chat art must fail closed before Provider planning"); + assert!(error.contains("External Editor API Key")); + assert!(error.contains("平台美术资源")); +} + +#[test] +fn game_chat_art_fast_path_idempotently_settles_registered_art_spec() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"game-chat-idempotent-art-key"}}"#.to_string(), + ); + + let art_director_temporary = tempfile::tempdir().expect("create idempotent art-spec root"); + let art_director_root = art_director_temporary.path().join("project"); + init_local_game_project_at( + &art_director_root, + "game-chat-idempotent-art-spec", + "海岛收集", + ) + .expect("init idempotent art-spec project"); + register_game_chat_art_spec_fixture(&art_director_root); + let (root_state, art_director_state) = queue_game_chat_fast_path_child( + &art_director_root, + "game-chat-idempotent-art-spec-root", + "制作海岛收集小游戏", + "art-director", + ); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &art_director_root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read idempotent art-spec root binding") + .expect("idempotent art-spec root binding exists") + .bound_at; + let art_director_plan = game_chat_fast_path_plan_at( + &art_director_root, + &art_director_state, + &art_director_state.current_task, + bound_at, + ) + .expect("settle registered art spec") + .expect("registered art spec must use deterministic settlement"); + assert!(art_director_plan.actions.is_empty()); + assert!(art_director_plan.response.contains("已生成并登记")); +} + +#[test] +fn game_chat_code_prototype_ignores_art_director_global_revision_before_its_own_mutation() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"game-chat-code-revision-key"}}"#.to_string(), + ); + let temporary = tempfile::tempdir().expect("create game-chat code revision root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-code-revision", "太空飞船收集能量") + .expect("init game-chat code revision project"); + let (root_state, art_director_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-code-revision-root", + "制作太空飞船收集能量小游戏", + "art-director", + ); + { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.game-chat.art-director.mutate", + ) + .expect("acquire art-director mutation lock"); + let revision = prepare_agent_runtime_project_mutation_locked( + &root, + &art_director_state.agent_id, + &art_director_state.run_id, + "canvas.asset_generate", + ) + .expect("advance global revision for art-director"); + assert_eq!(revision, 1); + } + + let manifest = read_manifest_for_project(&root).expect("read game-chat manifest"); + let code_task = manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .expect("code-prototype manifest task"); + let code_session = + resolve_agent_conversation_session_id_at(&root, "code-prototype", None, true) + .expect("resolve code-prototype session"); + let code_record = append_unique_game_creator_agent_runtime_pending_task( + &root, + "code-prototype", + &code_session, + &render_autonomous_manifest_ready_task_background_prompt(code_task), + &autonomous_manifest_ready_task_run_id(&root_state.run_id, "code-prototype"), + "agent-ready-task-scheduler", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(root_state.agent_id.clone()), + parent_run_id: Some(root_state.run_id.clone()), + delegation_id: None, + }), + ) + .expect("queue code-prototype child after art mutation"); + let code_state = agent_runtime_state_from_task_record(&code_record); + let code_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &code_state.agent_id, + &code_state.run_id, + ) + .expect("read code-prototype gate"); + assert_eq!(code_gate.mutation_revision, None); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read global revision") + .revision, + 1 + ); + let bound_at = 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") + .bound_at; + + let plan = game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at) + .expect("evaluate code-prototype fast path"); + + assert!( + plan.is_none(), + "art-director's global revision must not skip the code Provider" + ); +} + #[test] fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() { assert_eq!( @@ -331,7 +620,7 @@ fn game_chat_single_round_converges_without_another_provider_plan_after_playtest for task in new_game_creation_app_seed_tasks() { if !matches!( task.id.as_str(), - "code-prototype" | "preview-readiness" | "preview-playtest" + "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" ) { update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending) .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); @@ -398,7 +687,7 @@ fn game_chat_single_round_cannot_converge_after_the_hard_budget() { for task in new_game_creation_app_seed_tasks() { if !matches!( task.id.as_str(), - "code-prototype" | "preview-readiness" | "preview-playtest" + "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" ) { update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending) .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); 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 40057592f..6d886e21b 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 @@ -661,7 +661,8 @@ pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source( .into_iter() .filter_map(|mut task| { let dependencies = match task.id.as_str() { - "code-prototype" => Some(Vec::new()), + "art-director" => Some(Vec::new()), + "code-prototype" => Some(vec!["art-director".to_string()]), "preview-readiness" => Some(vec!["code-prototype".to_string()]), "preview-playtest" => Some(vec!["preview-readiness".to_string()]), _ => None, @@ -1161,8 +1162,12 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke manifest_task, &task_text, )?; + let game_chat_requires_visual_asset = root_parent_binding.source + == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && manifest_task.id == "art-director"; if status == GameCreationAppTaskStatus::Completed - && autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id) + && (autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id) + || game_chat_requires_visual_asset) && !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id) { status = GameCreationAppTaskStatus::Failed; @@ -1319,7 +1324,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 确认 assets/art-spritesheet.png 已登记且 source.kind=canvas、资源有效;game/index.html 必须实际通过 HTML、CSS background 或 Canvas drawImage 引用 assets/art-spritesheet.png 作为游戏 UI 素材,不得只用 emoji、色块、CSS 绘图或占位文本冒充。" + " External Editor API 已配置时必须先调用 asset.list 核对 Canvas 登记与资源有效性:game-chat Run 使用 assets/art-spec.png(icon-spec、images/generations/spec),GUI/CLI Run 使用 assets/art-spritesheet.png;game/index.html 必须通过 HTML、CSS background 或 Canvas drawImage 可见使用对应资源,不得只用 emoji、色块、CSS 绘图或占位文本冒充。" } 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 b0bf84d5c..ab6595834 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 @@ -384,17 +384,41 @@ fn autonomous_manifest_owner_artifact_gaps_at( fn autonomous_code_prototype_art_asset_reference_gap_at( root: &Path, task_id: &str, + required_visual_task_id: Option<&str>, ) -> Result, String> { - if task_id != "code-prototype" || !editor_api_key_is_configured() { + if task_id != "code-prototype" { return Ok(None); } + let Some(required_visual_task_id) = required_visual_task_id else { + return Ok(None); + }; + let (asset_path, asset_label) = match required_visual_task_id { + "art-director" => ("assets/art-spec.png", "art-spec"), + "art-asset-plan" => ("assets/art-spritesheet.png", "art-spritesheet"), + _ => { + return Err(format!( + "unsupported code-prototype visual gate task: {required_visual_task_id}" + )) + } + }; let manifest = read_manifest_for_project(root)?; - if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, "art-asset-plan") { + if let Err(error) = + validate_manifest_required_visual_asset(root, &manifest, required_visual_task_id) + { return Ok(Some(format!( - "assets/art-spritesheet.png(canvas-registration-invalid:{})", + "{asset_path}(canvas-registration-invalid:{})", sanitize_agent_runtime_text(&error, 240) ))); } + let asset_dimensions = manifest + .assets + .iter() + .find(|asset| asset.local_path == asset_path) + .and_then(|asset| resolve_local_project_path(root, &asset.local_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!("无法读取已验证视觉资产尺寸:{asset_path}"))?; let Some((_, html)) = read_autonomous_evidence_file_at( root, AGENT_RUNTIME_GAME_INDEX_PATH, @@ -402,21 +426,795 @@ fn autonomous_code_prototype_art_asset_reference_gap_at( AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, )? else { - return Ok(Some( - "game/index.html(missing-art-spritesheet-reference)".to_string(), - )); + return Ok(Some(format!( + "game/index.html(missing-visible-{asset_label}-use:{asset_path})" + ))); }; - if !html - .windows(b"assets/art-spritesheet.png".len()) - .any(|window| window == b"assets/art-spritesheet.png") - { - return Ok(Some( - "game/index.html(missing-art-spritesheet-reference)".to_string(), - )); + if !game_index_visibly_uses_visual_asset( + &html, + asset_path, + asset_dimensions, + required_visual_task_id == "art-director", + ) { + return Ok(Some(format!( + "game/index.html(missing-visible-{asset_label}-use:{asset_path})" + ))); } Ok(None) } +fn strip_art_reference_comments(content: &str) -> String { + let bytes = content.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut cursor = 0; + while cursor < bytes.len() { + if bytes[cursor..].starts_with(b"") { + cursor += 1; + } + cursor = (cursor + 3).min(bytes.len()); + } else if bytes[cursor..].starts_with(b"/*") { + cursor += 2; + while cursor < bytes.len() && !bytes[cursor..].starts_with(b"*/") { + cursor += 1; + } + cursor = (cursor + 2).min(bytes.len()); + } else if bytes[cursor..].starts_with(b"//") { + cursor += 2; + while cursor < bytes.len() && !matches!(bytes[cursor], b'\r' | b'\n') { + cursor += 1; + } + } else { + output.push(bytes[cursor]); + cursor += 1; + } + } + String::from_utf8(output).unwrap_or_default() +} + +fn strip_script_blocks(content: &str) -> String { + let mut output = String::with_capacity(content.len()); + let mut cursor = 0; + while let Some(start_offset) = content[cursor..].find("') else { + return output; + }; + let body_start = start + open_end_offset + 1; + let Some(end_offset) = content[body_start..].find("") else { + return output; + }; + cursor = body_start + end_offset + "".len(); + } + output.push_str(&content[cursor..]); + output +} + +fn relative_visual_url_resolves_to_asset(value: &str, asset_path: &str) -> bool { + let value = value + .trim() + .trim_matches(|character| matches!(character, '\'' | '"')); + let path = value.split(['?', '#']).next().unwrap_or_default().trim(); + if path.is_empty() + || path.starts_with('/') + || path.contains(['\\', '%', ':']) + || asset_path.starts_with('/') + { + return false; + } + let mut components = vec!["game"]; + for component in path.split('/') { + match component { + "" | "." => {} + ".." => { + if components.pop().is_none() { + return false; + } + } + value => components.push(value), + } + } + components.join("/") == asset_path +} + +fn html_attribute_value<'a>(tag: &'a str, attribute: &str) -> Option<&'a str> { + let bytes = tag.as_bytes(); + let attribute_bytes = attribute.as_bytes(); + let mut cursor = 0; + while cursor + attribute_bytes.len() <= bytes.len() { + let offset = tag[cursor..].find(attribute)?; + let start = cursor + offset; + let end = start + attribute_bytes.len(); + let left_boundary = + start == 0 || matches!(bytes[start - 1], b'<' | b' ' | b'\t' | b'\r' | b'\n'); + let right_boundary = + end == bytes.len() || matches!(bytes[end], b'=' | b' ' | b'\t' | b'\r' | b'\n'); + if !left_boundary || !right_boundary { + cursor = end; + continue; + } + let mut value_start = end; + while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + value_start += 1; + } + if bytes.get(value_start) != Some(&b'=') { + cursor = end; + continue; + } + value_start += 1; + while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + value_start += 1; + } + let quote = bytes.get(value_start).copied(); + if matches!(quote, Some(b'\'' | b'"')) { + value_start += 1; + let value_end = bytes[value_start..] + .iter() + .position(|byte| Some(*byte) == quote) + .map(|offset| value_start + offset)?; + return Some(&tag[value_start..value_end]); + } + let value_end = bytes[value_start..] + .iter() + .position(|byte| byte.is_ascii_whitespace() || *byte == b'>') + .map(|offset| value_start + offset) + .unwrap_or(bytes.len()); + return (value_end > value_start).then(|| &tag[value_start..value_end]); + } + None +} + +fn css_numeric_property(value: &str, property: &str) -> Option { + let compact = value + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let marker = format!("{property}:"); + let start = compact.find(&marker)? + marker.len(); + compact[start..] + .chars() + .take_while(|character| character.is_ascii_digit() || matches!(character, '.' | '-')) + .collect::() + .parse() + .ok() +} + +fn tag_dimension(tag: &str, attribute: &str, property: &str) -> Option { + html_attribute_value(tag, attribute) + .and_then(|value| value.trim_end_matches("px").parse().ok()) + .or_else(|| { + html_attribute_value(tag, "style") + .and_then(|style| css_numeric_property(style, property)) + }) +} + +fn tag_is_obviously_hidden_or_tiny(tag: &str, default_dimensions: (u32, u32)) -> bool { + let compact = tag + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let hidden_attribute = tag + .split(|character: char| character.is_ascii_whitespace() || matches!(character, '<' | '>')) + .any(|token| token == "hidden" || token.starts_with("hidden=")); + if hidden_attribute + || compact.contains("display:none") + || compact.contains("visibility:hidden") + || compact.contains("left:-999") + || compact.contains("top:-999") + || compact.contains("translate(-999") + || css_numeric_property(tag, "opacity").is_some_and(|opacity| opacity < 0.25) + { + return true; + } + let width = tag_dimension(tag, "width", "width").unwrap_or(default_dimensions.0 as f64); + let height = tag_dimension(tag, "height", "height").unwrap_or(default_dimensions.1 as f64); + width < 24.0 || height < 24.0 +} + +fn css_contains_resolving_url(style: &str, asset_path: &str) -> bool { + let mut cursor = 0; + while let Some(offset) = style[cursor..].find("url(") { + let start = cursor + offset + "url(".len(); + let Some(end_offset) = style[start..].find(')') else { + return false; + }; + let end = start + end_offset; + if relative_visual_url_resolves_to_asset(&style[start..end], asset_path) { + return true; + } + cursor = end + 1; + } + false +} + +fn selector_is_bound_to_markup(selector: &str, markup: &str) -> bool { + selector.split(',').any(|selector| { + let selector = selector.trim(); + if let Some(class_name) = selector.strip_prefix('.') { + let class_name = class_name + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return !class_name.is_empty() + && markup.split('<').any(|tag| { + html_attribute_value(tag, "class").is_some_and(|classes| { + classes + .split_ascii_whitespace() + .any(|value| value == class_name) + }) + }); + } + if let Some(id) = selector.strip_prefix('#') { + let id = id + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return !id.is_empty() + && markup + .split('<') + .any(|tag| html_attribute_value(tag, "id") == Some(id)); + } + let tag_name = selector + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .next() + .unwrap_or_default(); + !tag_name.is_empty() + && markup + .split('<') + .any(|tag| tag.trim_start().starts_with(tag_name)) + }) +} + +fn selector_matches_tag(selector: &str, tag: &str) -> bool { + selector.split(',').any(|selector| { + let selector = selector.trim(); + if let Some(class_name) = selector.strip_prefix('.') { + let class_name = class_name + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return html_attribute_value(tag, "class").is_some_and(|classes| { + classes + .split_ascii_whitespace() + .any(|value| value == class_name) + }); + } + if let Some(id) = selector.strip_prefix('#') { + let id = id + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return html_attribute_value(tag, "id") == Some(id); + } + let name = selector + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .next() + .unwrap_or_default(); + !name.is_empty() + && tag + .trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with(name) + }) +} + +fn tag_is_hidden_by_stylesheet(tag: &str, markup: &str, default_dimensions: (u32, u32)) -> bool { + markup.split("').map(|offset| offset + 1) else { + return false; + }; + let Some(body_end) = tail[body_start..].find("") else { + return false; + }; + tail[body_start..body_start + body_end] + .split('}') + .any(|rule| { + rule.rsplit_once('{') + .is_some_and(|(selector, declarations)| { + selector_matches_tag(selector, tag) + && tag_is_obviously_hidden_or_tiny( + &format!("
"), + default_dimensions, + ) + }) + }) + }) +} + +fn position_is_inside_javascript_string(content: &str, position: usize) -> bool { + let mut quote = None; + let mut escaped = false; + for byte in content.as_bytes().iter().copied().take(position) { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } + } + quote.is_some() +} + +fn matching_javascript_brace(content: &str, open: usize) -> Option { + let mut depth = 0usize; + let mut quote = None; + let mut escaped = false; + for (offset, byte) in content.as_bytes()[open..].iter().copied().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'{' { + depth += 1; + } else if byte == b'}' { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(open + offset); + } + } + } + None +} + +fn named_javascript_function_ranges(content: &str) -> Vec<(String, usize, usize)> { + let mut ranges = Vec::new(); + let mut cursor = 0; + while let Some(offset) = content[cursor..].find("function ") { + let definition_start = cursor + offset; + cursor = definition_start + "function ".len(); + if position_is_inside_javascript_string(content, definition_start) { + continue; + } + while content + .as_bytes() + .get(cursor) + .is_some_and(u8::is_ascii_whitespace) + { + cursor += 1; + } + let name_start = cursor; + while content + .as_bytes() + .get(cursor) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'$') + { + cursor += 1; + } + if cursor == name_start { + continue; + } + let name = content[name_start..cursor].to_string(); + let Some(open_offset) = content[cursor..].find('{') else { + break; + }; + let open = cursor + open_offset; + let Some(end) = matching_javascript_brace(content, open) else { + break; + }; + ranges.push((name, definition_start, end + 1)); + cursor = open + 1; + } + ranges +} + +fn javascript_named_function_is_reachable( + content: &str, + ranges: &[(String, usize, usize)], + function_index: usize, + visiting: &mut BTreeSet, +) -> bool { + if !visiting.insert(function_index) { + return false; + } + let (name, definition_start, definition_end) = &ranges[function_index]; + let invocation_markers = [ + format!("{name}("), + format!("requestanimationframe({name})"), + format!(",{name})"), + format!(", {name})"), + ]; + for marker in invocation_markers { + let mut cursor = 0; + while let Some(offset) = content[cursor..].find(&marker) { + let call = cursor + offset; + cursor = call + marker.len(); + if (*definition_start..*definition_end).contains(&call) + || position_is_inside_javascript_string(content, call) + { + continue; + } + let parent = ranges + .iter() + .enumerate() + .filter(|(_, (_, start, end))| (*start..*end).contains(&call)) + .min_by_key(|(_, (_, start, end))| end - start) + .map(|(index, _)| index); + if parent.is_none_or(|index| { + javascript_named_function_is_reachable(content, ranges, index, visiting) + }) { + visiting.remove(&function_index); + return true; + } + } + } + visiting.remove(&function_index); + false +} + +fn javascript_position_is_reachable( + content: &str, + ranges: &[(String, usize, usize)], + position: usize, +) -> bool { + let enclosing = ranges + .iter() + .enumerate() + .filter(|(_, (_, start, end))| (*start..*end).contains(&position)) + .min_by_key(|(_, (_, start, end))| end - start) + .map(|(index, _)| index); + enclosing.is_none_or(|index| { + javascript_named_function_is_reachable(content, ranges, index, &mut BTreeSet::new()) + }) +} + +fn identifier_before(content: &str, position: usize) -> Option { + let bytes = content.as_bytes(); + let mut end = position; + while end > 0 && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + let mut start = end; + while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') { + start -= 1; + } + (start < end).then(|| content[start..end].to_string()) +} + +fn canvas_visual_identifiers(content: &str, markup: &str, asset_path: &str) -> BTreeSet { + let mut identifiers = BTreeSet::new(); + let mut cursor = 0; + while let Some(offset) = content[cursor..].find(".src") { + let dot = cursor + offset; + cursor = dot + 4; + if position_is_inside_javascript_string(content, dot) { + continue; + } + let Some(identifier) = identifier_before(content, dot) else { + continue; + }; + let mut value_start = cursor; + while content + .as_bytes() + .get(value_start) + .is_some_and(u8::is_ascii_whitespace) + { + value_start += 1; + } + if content.as_bytes().get(value_start) != Some(&b'=') { + continue; + } + value_start += 1; + while content + .as_bytes() + .get(value_start) + .is_some_and(u8::is_ascii_whitespace) + { + value_start += 1; + } + let Some(quote @ (b'\'' | b'"' | b'`')) = content.as_bytes().get(value_start).copied() + else { + continue; + }; + value_start += 1; + let Some(end_offset) = content.as_bytes()[value_start..] + .iter() + .position(|byte| *byte == quote) + else { + continue; + }; + if relative_visual_url_resolves_to_asset( + &content[value_start..value_start + end_offset], + asset_path, + ) { + identifiers.insert(identifier); + } + } + + for tag in markup.split('>').filter(|tag| tag.contains(asset_path)) { + let Some(id) = html_attribute_value(tag, "id") else { + continue; + }; + let source_matches = ["src", "href", "data", "poster"].iter().any(|attribute| { + html_attribute_value(tag, attribute) + .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) + }); + if !source_matches { + continue; + } + for marker in ["getelementbyid(", "queryselector("] { + let mut binding_cursor = 0; + while let Some(offset) = content[binding_cursor..].find(marker) { + let call = binding_cursor + offset; + binding_cursor = call + marker.len(); + if position_is_inside_javascript_string(content, call) { + continue; + } + let argument_tail = &content[binding_cursor..]; + let Some(argument_end) = argument_tail.find(')') else { + continue; + }; + let argument = argument_tail[..argument_end] + .trim() + .trim_matches(|character| matches!(character, '\'' | '"' | '#')); + if argument != id { + continue; + } + let Some(equals) = content[..call].rfind('=') else { + continue; + }; + if call.saturating_sub(equals) > 24 { + continue; + } + if let Some(identifier) = identifier_before(content, equals) { + identifiers.insert(identifier); + } + } + } + } + identifiers +} + +fn split_javascript_arguments(arguments: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut start = 0; + let mut depth: usize = 0; + let mut quote = None; + let mut escaped = false; + for (index, byte) in arguments.bytes().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'(' { + depth += 1; + } else if byte == b')' { + depth = depth.saturating_sub(1); + } else if byte == b',' && depth == 0 { + result.push(arguments[start..index].trim()); + start = index + 1; + } + } + result.push(arguments[start..].trim()); + result +} + +fn javascript_call_arguments_end(content: &str, start: usize) -> Option { + let mut depth: usize = 0; + let mut quote = None; + let mut escaped = false; + for (offset, byte) in content.as_bytes()[start..].iter().copied().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'(' { + depth += 1; + } else if byte == b')' { + if depth == 0 { + return Some(start + offset); + } + depth -= 1; + } + } + None +} + +fn draw_image_metrics(arguments: &[&str], asset_dimensions: (u32, u32)) -> (bool, bool, bool) { + let destination = match arguments.len() { + 3 => { + let significant = asset_dimensions.0 >= 32 && asset_dimensions.1 >= 32; + return (significant, significant, false); + } + 5 => arguments.get(3).zip(arguments.get(4)), + 9 => arguments.get(7).zip(arguments.get(8)), + _ => None, + }; + let Some((width, height)) = destination else { + return (false, false, false); + }; + let compact_width = width.split_ascii_whitespace().collect::(); + let compact_height = height.split_ascii_whitespace().collect::(); + let background = compact_width.ends_with(".width") + && compact_height.ends_with(".height") + && compact_width.trim_end_matches(".width") == compact_height.trim_end_matches(".height"); + let parse = |value: &str| value.trim().parse::().ok(); + let numeric = parse(width).zip(parse(height)); + let entity = numeric.is_some_and(|(width, height)| { + width.abs() >= 32.0 && height.abs() >= 32.0 && width.abs() * height.abs() >= 2048.0 + }); + let numeric_background = numeric.is_some_and(|(width, height)| { + width.abs() >= 320.0 && height.abs() >= 180.0 && width.abs() * height.abs() >= 100_000.0 + }); + let background = background || numeric_background; + (background || entity, background, entity) +} + +fn tag_visibly_uses_visual_asset( + tag: &str, + asset_path: &str, + asset_dimensions: (u32, u32), +) -> bool { + let tag = tag.to_ascii_lowercase(); + if !tag.contains(asset_path) || tag_is_obviously_hidden_or_tiny(&tag, asset_dimensions) { + return false; + } + let trimmed = tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']); + let visual_element = ["img", "image", "object", "embed", "video", "input"] + .iter() + .any(|name| { + trimmed.starts_with(name) + && trimmed + .as_bytes() + .get(name.len()) + .is_some_and(|byte| byte.is_ascii_whitespace() || *byte == b'>') + }); + let direct_source = visual_element + && ["src", "href", "data", "poster"].iter().any(|attribute| { + html_attribute_value(&tag, attribute) + .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) + }); + direct_source + || html_attribute_value(&tag, "style") + .is_some_and(|style| css_contains_resolving_url(style, asset_path)) +} + +fn game_index_visibly_uses_visual_asset( + html: &[u8], + asset_path: &str, + asset_dimensions: (u32, u32), + require_canvas_composition: bool, +) -> bool { + let asset_path = asset_path.to_ascii_lowercase(); + let Ok(html) = std::str::from_utf8(html) else { + return false; + }; + let content = strip_art_reference_comments(html).to_ascii_lowercase(); + if !content.contains(&asset_path) { + return false; + } + let markup = strip_script_blocks(&content); + + let mut tag_cursor = 0; + while let Some(start_offset) = markup[tag_cursor..].find('<') { + let start = tag_cursor + start_offset; + let Some(end_offset) = markup[start..].find('>') else { + break; + }; + let end = start + end_offset + 1; + let tag = &markup[start..end]; + if !require_canvas_composition + && tag_visibly_uses_visual_asset(tag, &asset_path, asset_dimensions) + && !tag_is_hidden_by_stylesheet(tag, &markup, asset_dimensions) + { + return true; + } + tag_cursor = end; + } + + let mut style_cursor = 0; + while let Some(start_offset) = markup[style_cursor..].find("') else { + break; + }; + let body_start = start + open_end_offset + 1; + let Some(end_offset) = markup[body_start..].find("") else { + break; + }; + let body_end = body_start + end_offset; + let style = &markup[body_start..body_end]; + for rule in style.split('}') { + let Some((selector, declarations)) = rule.rsplit_once('{') else { + continue; + }; + if !require_canvas_composition + && css_contains_resolving_url(declarations, &asset_path) + && selector_is_bound_to_markup(selector, &markup) + && !tag_is_obviously_hidden_or_tiny( + &format!("
"), + (0, 0), + ) + { + return true; + } + } + style_cursor = body_end + "".len(); + } + + let active_canvas = markup.split('>').any(|tag| { + tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with("canvas") + && !tag_is_obviously_hidden_or_tiny(tag, (300, 150)) + && !tag_is_hidden_by_stylesheet(tag, &markup, (300, 150)) + }); + if !active_canvas { + return false; + } + let identifiers = canvas_visual_identifiers(&content, &markup, &asset_path); + if identifiers.is_empty() { + 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(") { + let call = draw_cursor + offset; + let arguments_start = call + "drawimage(".len(); + draw_cursor = arguments_start; + if position_is_inside_javascript_string(&content, call) + || !javascript_position_is_reachable(&content, &function_ranges, call) + { + continue; + } + let Some(arguments_end) = javascript_call_arguments_end(&content, arguments_start) else { + break; + }; + let arguments = split_javascript_arguments(&content[arguments_start..arguments_end]); + if arguments + .first() + .is_some_and(|identifier| identifiers.contains(*identifier)) + { + let (significant, background, entity) = + 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 { + return true; + } + } + draw_cursor = arguments_end + 1; + } + require_canvas_composition + && significant_draws >= 3 + && background_draws >= 1 + && entity_draws >= 2 +} + pub(in crate::agent) fn game_creation_app_task_status_label( status: &GameCreationAppTaskStatus, ) -> String { @@ -438,6 +1236,13 @@ 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") + } else if editor_api_key_is_configured() { + Some("art-asset-plan") + } else { + None + }; let mut missing_tasks = Vec::new(); let mut missing_paths = Vec::new(); for seed_task in &seed_tasks { @@ -456,12 +1261,23 @@ fn autonomous_manifest_parent_completion_gaps_at( contract.baseline_index_sha256.as_deref(), &contract.baseline_artifacts, )?); - if seed_task.id == "code-prototype" - && binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE - { - if let Some(gap) = - autonomous_code_prototype_art_asset_reference_gap_at(root, &seed_task.id)? + if required_visual_task_id == Some(seed_task.id.as_str()) { + if let Err(error) = + validate_manifest_required_visual_asset(root, &manifest, &seed_task.id) { + missing_paths.push(AutonomousManifestArtifactGap::new(format!( + "{}(canvas-registration-invalid:{})", + seed_task.id, + sanitize_agent_runtime_text(&error, 240) + ))); + } + } + if seed_task.id == "code-prototype" { + if let Some(gap) = autonomous_code_prototype_art_asset_reference_gap_at( + root, + &seed_task.id, + required_visual_task_id, + )? { missing_paths.push(AutonomousManifestArtifactGap::new(gap)); } } @@ -662,17 +1478,37 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - let platform_art_blocks_delivery = root_source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE; - match platform_art_blocks_delivery - .then(|| autonomous_code_prototype_art_asset_reference_gap_at(root, &state.agent_id)) - .transpose() - .map(Option::flatten) - { + let required_visual_task_id = if root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + Some("art-director") + } else if editor_api_key_is_configured() { + Some("art-asset-plan") + } else { + None + }; + if required_visual_task_id == Some(state.agent_id.as_str()) { + if let Err(error) = + validate_manifest_required_visual_asset(root, &manifest, &state.agent_id) + { + return Some(autonomous_completion_blocker( + "autonomous ready-task 缺少有效的 Canvas 美术资产登记", + format!( + "task={} validationError={}", + state.agent_id, + sanitize_agent_runtime_text(&error, 240) + ), + )); + } + } + match autonomous_code_prototype_art_asset_reference_gap_at( + root, + &state.agent_id, + required_visual_task_id, + ) { Ok(Some(gap)) => { return Some(autonomous_completion_blocker( "code-prototype 必须实际使用平台生成的美术资源", format!( - "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中引用 assets/art-spritesheet.png", + "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中可见使用对应平台美术资源", state.agent_id, gap ), )); 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 e47cbbd3e..ccdc990c2 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_is_a_serial_three_task_lane() { +fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() { let game_chat_tasks = autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); assert_eq!( @@ -80,15 +80,24 @@ fn game_chat_manifest_seed_projection_is_a_serial_three_task_lane() { .iter() .map(|task| task.id.as_str()) .collect::>(), - ["code-prototype", "preview-readiness", "preview-playtest"] + [ + "art-director", + "code-prototype", + "preview-readiness", + "preview-playtest", + ] ); assert_eq!(game_chat_tasks[0].dependencies, Vec::::new()); assert_eq!( game_chat_tasks[1].dependencies, - vec!["code-prototype".to_string()] + vec!["art-director".to_string()] ); assert_eq!( game_chat_tasks[2].dependencies, + vec!["code-prototype".to_string()] + ); + assert_eq!( + game_chat_tasks[3].dependencies, vec!["preview-readiness".to_string()] ); @@ -107,7 +116,7 @@ fn game_chat_manifest_seed_projection_is_a_serial_three_task_lane() { for task in &mut manifest_tasks { if matches!( task.id.as_str(), - "code-prototype" | "preview-readiness" | "preview-playtest" + "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" ) { task.status = GameCreationAppTaskStatus::Pending; } @@ -117,20 +126,20 @@ fn game_chat_manifest_seed_projection_is_a_serial_three_task_lane() { &manifest_tasks, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, ), - vec!["code-prototype".to_string()] + vec!["art-director".to_string()] ); manifest_tasks .iter_mut() - .find(|task| task.id == "code-prototype") - .expect("code prototype task exists") + .find(|task| task.id == "art-director") + .expect("art director task exists") .status = GameCreationAppTaskStatus::Completed; assert_eq!( autonomous_manifest_ready_task_ids( &manifest_tasks, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, ), - vec!["preview-readiness".to_string()] + vec!["code-prototype".to_string()] ); } @@ -179,16 +188,13 @@ fn autonomous_fixture_with_setup( } fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) { - let bytes = if kind == "art-spritesheet" { - use image::ImageEncoder; - let mut bytes = Vec::new(); - image::codecs::png::PngEncoder::new(&mut bytes) - .write_image(&[12, 34, 56, 0], 1, 1, image::ColorType::Rgba8.into()) - .expect("encode transparent autonomous visual fixture"); - bytes - } else { - b"\x89PNG\r\n\x1a\nfixture".to_vec() - }; + use image::ImageEncoder; + let alpha = if kind == "art-spritesheet" { 0 } else { 255 }; + let pixels = [12, 34, 56, alpha].repeat(64 * 64); + let mut bytes = Vec::new(); + image::codecs::png::PngEncoder::new(&mut bytes) + .write_image(&pixels, 64, 64, image::ColorType::Rgba8.into()) + .expect("encode autonomous visual fixture"); fs::write(root.join(local_path), bytes).expect("write autonomous visual fixture"); let (generation_route, generation_kind, reference_resource_ids) = match kind { "icon-spec" => ( @@ -1074,7 +1080,7 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task for task in new_game_creation_app_seed_tasks() { if !matches!( task.id.as_str(), - "code-prototype" | "preview-readiness" | "preview-playtest" + "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" ) { update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending) .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); @@ -1083,7 +1089,7 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task let revision = advance_game_index_revision( &root, &state, - "", + "", ); mark_verification_passed(&root, &state, "game.static_smoke"); let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario); @@ -1142,24 +1148,160 @@ fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary } #[test] -fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_is_configured() { +fn game_chat_code_prototype_requires_registered_canvas_art_spec_visible_use() { let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"game-chat-art-gate-key"}}"#.to_string(), ); - let (_temporary, root, parent_state, _contract) = - autonomous_fixture("创建一轮植物塔防游戏", "game-chat-code-art-gate-parent"); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-code-art-gate-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) .expect("mark code prototype running"); 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); + let code_state = agent_runtime_state_from_task_record(&code_record); advance_game_index_revision( &root, &code_state, "", ); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) - .expect("missing spritesheet reference must block code prototype"); + .expect("missing art-spec reference must block code prototype"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("assets/art-spec.png"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(), + "URL relative to game/index.html must resolve to the registered asset" + ); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("an unused art-spec string must not satisfy visible-use validation"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("missing-visible-art-spec-use"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + for hidden_html in [ + "", + "", + "", + ] { + advance_game_index_revision(&root, &code_state, hidden_html); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + } + + advance_game_index_revision( + &root, + &code_state, + "
", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); +} + +#[test] +fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_configured() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"cli-art-gate-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("创建完整小游戏", "cli-code-art-gate-parent"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark CLI code prototype running"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("CLI must still require the art spritesheet"); assert!(blocker .detail .as_deref() @@ -1168,11 +1310,62 @@ fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_i advance_game_index_revision( &root, &code_state, - "", + "", ); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); } +#[test] +fn game_chat_requires_canvas_art_spec_even_without_editor_configuration() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮必须使用平台美术的小游戏", + "game-chat-unconfigured-art-gate-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + 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 registered art-spec file"); + advance_game_index_revision( + &root, + &code_state, + "", + ); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("game-chat must fail closed without a valid Canvas art spec"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("canvas-registration-invalid"))); +} + +#[test] +fn game_chat_art_stage_fails_closed_without_editor_configuration_or_canvas_asset() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮必须先完成美术阶段的小游戏", + "game-chat-unconfigured-art-stage-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Running) + .expect("mark art director running"); + let art_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "art-director"); + let art_state = agent_runtime_state_from_task_record(&art_record); + fs::remove_file(root.join("assets/art-spec.png")).expect("remove Canvas art spec file"); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &art_state) + .expect("game-chat art stage must fail closed without a valid Canvas asset"); + assert!(blocker.summary.contains("Canvas")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("task=art-director"))); +} + #[test] fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pending() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); @@ -1189,7 +1382,7 @@ fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pendi advance_game_index_revision( &root, &code_state, - "", + "", ); assert!( 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 62a99e691..ce731e574 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 @@ -156,7 +156,12 @@ mod tests { None, ) .expect("bind game-chat run"); - for task_id in ["code-prototype", "preview-readiness", "preview-playtest"] { + for task_id in [ + "art-director", + "code-prototype", + "preview-readiness", + "preview-playtest", + ] { update_manifest_task_status_at(root, task_id, GameCreationAppTaskStatus::Completed) .expect("complete game-chat seed task"); } @@ -173,13 +178,13 @@ mod tests { assert!(!detail.contains("publish-package"), "{detail}"); assert!( detail.contains( - "seedTaskCounts: completed=3 running=0 pending=0 waiting=0 failed=0 total=3" + "seedTaskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4" ), "{detail}" ); assert!( detail - .contains("taskCounts: completed=3 running=0 pending=0 waiting=0 failed=0 total=3"), + .contains("taskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4"), "{detail}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index ae54a9ba5..91fb6a9ff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -258,11 +258,12 @@ pub(crate) fn validate_manifest_required_visual_asset( .and_then(|path| fs::read(path).ok()) .filter(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")) .ok_or_else(|| format!("规范视觉资产不是有效登记的 PNG 文件:{expected_path}"))?; - if task_id == "art-asset-plan" - && !image::load_from_memory(&bytes) - .ok() - .is_some_and(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX)) - { + let decoded = image::load_from_memory(&bytes) + .map_err(|_| format!("规范视觉资产 PNG 无法完整解码:{expected_path}"))?; + if decoded.width() == 0 || decoded.height() == 0 { + return Err(format!("规范视觉资产 PNG 尺寸无效:{expected_path}")); + } + if task_id == "art-asset-plan" && !decoded.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX) { return Err("首版美术素材图没有真实透明像素".to_string()); } let canvas_project_id = asset diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index c1f4aca90..344461628 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -331,6 +331,17 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); } + let art_spec_path = root.join("assets/art-spec.png"); + let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture"); + fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2]) + .expect("write truncated art spec PNG"); + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-director") + .expect_err("truncated PNG must fail complete decode") + .contains("无法完整解码") + ); + fs::write(&art_spec_path, valid_art_spec).expect("restore valid art spec PNG"); + let ui = manifest .assets .iter_mut() diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f37880200..b73e87724 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -268,6 +268,7 @@ type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { }; const GAME_CHAT_STAGE_TASK_IDS = [ + 'art-director', 'code-prototype', 'preview-readiness', 'preview-playtest', @@ -292,6 +293,30 @@ function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) { ); } +function mergeGameChatHydratedConversationMessages( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const mergedMessages = mergeGameChatRuntimeEventMessagesIntoHistory( + mergeGameChatFinalReplyMessagesIntoHistory( + mergeGameChatRuntimeResponseMessagesIntoHistory( + historyMessages, + currentMessages, + ), + currentMessages, + ), + currentMessages, + ); + const historyMessageReferences = new Set(historyMessages); + const pendingMessages = mergedMessages + .filter((message) => !historyMessageReferences.has(message)) + .sort((left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0)); + // Conversation persistence uses a positional cursor. Keep durable history + // as one prefix so newly observed replies cannot sort ahead of that cursor + // and be skipped during hydration. + return [...historyMessages, ...pendingMessages]; +} + function gameChatPlayableRevisionIsAfterAuthorization( revision: GameChatPlayableRevision, authorization: GameChatAutoPreviewAuthorization, @@ -2711,14 +2736,8 @@ export function App({ // so a committed game-chat response cannot be overwritten by stale // `latestMessagesRef` state captured before that callback ran. const nextMessages = gameChatOnly - ? mergeGameChatRuntimeEventMessagesIntoHistory( - mergeGameChatFinalReplyMessagesIntoHistory( - mergeGameChatRuntimeResponseMessagesIntoHistory( - conversationMessages, - current, - ), - current, - ), + ? mergeGameChatHydratedConversationMessages( + conversationMessages, current, ) : conversationMessages; @@ -2848,14 +2867,8 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); setMessages((current) => { const nextConversationMessages = gameChatOnly - ? mergeGameChatRuntimeEventMessagesIntoHistory( - mergeGameChatFinalReplyMessagesIntoHistory( - mergeGameChatRuntimeResponseMessagesIntoHistory( - conversationMessages, - current, - ), - current, - ), + ? mergeGameChatHydratedConversationMessages( + conversationMessages, current, ) : conversationMessages; 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 3849bca80..e22e0abd7 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 @@ -58,6 +58,7 @@ export type GameChatRuntimeEvent = { const GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX = 'game-chat-runtime-event:'; const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:'; const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([ + 'art-director', 'code-prototype', 'preview-readiness', 'preview-playtest', @@ -323,6 +324,7 @@ export function buildGameChatProgressEvidence( return null; } const fastPathTaskIds = new Set([ + 'art-director', 'code-prototype', 'preview-readiness', 'preview-playtest', 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 685fbe0f7..3b1fbba5e 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 @@ -132,6 +132,17 @@ function gameChatRuntimeState( }; } +const GAME_CHAT_STAGE_TASK_IDS = [ + 'art-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +] as const; + +function isGameChatStageTask(taskId: string) { + return (GAME_CHAT_STAGE_TASK_IDS as readonly string[]).includes(taskId); +} + function gameChatPreviewPlaytestRuntime({ parentRunId, revision, @@ -2834,6 +2845,8 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 200 + responseRevision, }); const messages = gameChatFinalReplyMessages([ + makeStream('art-director', 'ready', '视觉方向已完成'), + makeStream('art-asset-plan', 'committed', '平台美术图集已生成并登记'), makeStream('code-prototype', 'ready', '代码原型已完成'), makeStream('preview-readiness', 'committed', '预览就绪检查已完成'), makeStream('preview-playtest', 'ready', '试玩验证已完成'), @@ -2842,13 +2855,14 @@ export function registerProjectSupervisorSurfaceTests() { requestKind: 'tool-plan', }, ]); - expect(messages).toHaveLength(3); + expect(messages).toHaveLength(4); expect(messages.map((message) => message.text)).toEqual([ + expect.stringContaining('视觉方向已完成'), expect.stringContaining('代码原型已完成'), expect.stringContaining('预览就绪检查已完成'), expect.stringContaining('试玩验证已完成'), ]); - expect(messages[0]?.messageId).toContain('code-prototype'); + expect(messages[0]?.messageId).toContain('art-director'); expect(messages[0]?.messageId).toContain('game-chat-final-reply:'); expect(messages.every((message) => message.agentId)).toBe(true); const hydrated = mergeGameChatFinalReplyMessagesIntoHistory( @@ -2910,6 +2924,13 @@ export function registerProjectSupervisorSurfaceTests() { }; }; const professionalResults = [ + makeRuntimeResult('art-director', 'ready', '视觉方向已完成', 180), + makeRuntimeResult( + 'art-asset-plan', + 'committed', + '平台美术图集已生成并登记', + 190, + ), makeRuntimeResult('code-prototype', 'ready', '代码原型已完成', 200), makeRuntimeResult('preview-readiness', 'committed', '预览就绪已完成', 210), makeRuntimeResult('preview-playtest', 'ready', '试玩验证已完成', 220), @@ -2951,6 +2972,8 @@ 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(); @@ -2962,14 +2985,14 @@ export function registerProjectSupervisorSurfaceTests() { String(args?.messageId ?? '').startsWith('game-chat-final-reply:'), ); await waitFor(() => { - expect(finalReplyAppends()).toHaveLength(3); + expect(finalReplyAppends()).toHaveLength(4); }); rendered.unmount(); rendered = renderRelease(); await waitFor(() => { expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); }); - expect(finalReplyAppends()).toHaveLength(3); + expect(finalReplyAppends()).toHaveLength(4); rendered.unmount(); }); @@ -3535,15 +3558,13 @@ export function registerProjectSupervisorSurfaceTests() { expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)'); }); - it('counts only the three first-playable fast-path tasks in game-chat progress', () => { + it('counts only the four first-playable fast-path tasks in game-chat progress', () => { const manifest = createGameCreationAppManifest( 'game-chat-progress-total', 'game-chat-progress-total', ); manifest.tasks = manifest.tasks.map((task) => - ['code-prototype', 'preview-readiness', 'preview-playtest'].includes( - task.id, - ) + isGameChatStageTask(task.id) ? { ...task, status: 'completed' as const } : task, ); @@ -3557,7 +3578,7 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime, manifest }); const progress = screen.getByLabelText('Supervisor 进度播报'); - expect(progress.textContent).toContain('任务图 3/3'); + expect(progress.textContent).toContain('任务图 4/4'); expect(progress.textContent).not.toContain('publish-strategy'); expect(progress.textContent).not.toContain('publish-package'); }); @@ -3736,7 +3757,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(progress).queryByText(/第 4 轮/u)).toBeNull(); expect( within(progress).getByText( - '任务图 0/3 · 进行中 1 · 计划 1/3', + '任务图 1/4 · 进行中 1 · 计划 1/3', ), ).not.toBeNull(); expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull(); @@ -3814,7 +3835,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(progress).queryByText(/第 5 轮/u)).toBeNull(); expect( within(progress).getByText( - '任务图 0/3 · 进行中 1 · 计划 2/3', + '任务图 1/4 · 进行中 1 · 计划 2/3', ), ).not.toBeNull(); expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull(); @@ -4044,9 +4065,7 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-stage-record', ); manifest.tasks = manifest.tasks.map((task) => - ['code-prototype', 'preview-readiness', 'preview-playtest'].includes( - task.id, - ) + isGameChatStageTask(task.id) ? { ...task, status: 'completed' as const } : task, ); @@ -4280,7 +4299,11 @@ export function registerProjectSupervisorSurfaceTests() { terminalManifest.tasks = terminalManifest.tasks.map((task) => task.id === 'preview-playtest' ? { ...task, status: 'failed' as const } - : ['code-prototype', 'preview-readiness'].includes(task.id) + : [ + 'art-director', + 'code-prototype', + 'preview-readiness', + ].includes(task.id) ? { ...task, status: 'completed' as const } : task, ); @@ -4417,7 +4440,7 @@ export function registerProjectSupervisorSurfaceTests() { (stageRecordAppends()[0]?.[1] as { message?: { content?: string } }) ?.message?.content ?? '', ); - expect(stageRecord).toContain('2/3'); + expect(stageRecord).toContain('3/4'); }); it('archives a terminal game-chat run restored during initial hydration exactly once', async () => { @@ -4428,9 +4451,7 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-stage-record-initial-terminal', ); manifest.tasks = manifest.tasks.map((task) => - ['code-prototype', 'preview-readiness', 'preview-playtest'].includes( - task.id, - ) + isGameChatStageTask(task.id) ? { ...task, status: 'completed' as const } : task, ); @@ -4550,9 +4571,7 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-stage-record-restart-hydration', ); manifest.tasks = manifest.tasks.map((task) => - ['code-prototype', 'preview-readiness', 'preview-playtest'].includes( - task.id, - ) + isGameChatStageTask(task.id) ? { ...task, status: 'completed' as const } : task, ); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d04160a91..d23cf419a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5875,9 +5875,9 @@ ## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用 - 背景:game-chat 的 ready response 之前只作为 transient stream 展示,专业 Agent 的 `final-reply` 只进入各自私有 conversation,刷新或事件 / 轮询重放时项目聊天可能丢失这些输出;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 -- 决策:game-chat 为 Supervisor ready 输出、三阶段专业 Agent `final-reply` 和每条后端批准公开的 Runtime 输出分配稳定消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到项目聊天。专业回复只接受 `code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed`;tool-plan 与流式半成品不进入聊天。Runtime 事件由 Rust 在写事件时生成唯一 `eventId` 和可选 `publicText`,前端只消费这两个字段,不重新解释 `summary / detail`;无公开投影的 legacy、Provider、Runner、tool payload、路径、指纹、哈希和敏感字段不得写 conversation。`append_local_conversation_message` 通过顶层 `messageId` 使用后端幂等追加。普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke` 和 `preview.validate`,不再调度 `publish-strategy` / `publish-package`,GUI / CLI 仍执行完整 DAG。`agent.schedule_ready` 同样必须按当前父 Run 的持久 source/profile 走 source-aware scheduler,不得回退通用完整 DAG;`task.list` 必须隐藏两个发布节点及其 ready/count 投影,`agent.delegate` 必须根据 root binding 拒绝直接委派这两个节点,所有绑定读取错误均失败关闭。 -- 单轮语义:Runtime 的 `loopIteration` 是同一父 Run 内的 Provider / 工具循环,不是用户可见的游戏版本轮次;game-chat 的进度卡、当前工作和最新状态统一显示“本轮”,不显示根或专业 Agent 的内部“第 N 轮”,完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,首版快车道改为三阶段 `x/3`,终态后移除运行中进度卡。`preview-playtest` 与全部完成门满足且非验证屏障清零后,Runtime 以确定性最终回复完成结构化计划并立即结算父 Run,不再把“是否继续”交给下一次 Provider tool-plan。 -- 美术资源门禁:External Editor API 有效时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 真实引用该文件;manifest、文件或 HTML 引用任一缺失均拒绝完成。确定性 Provider fixture 也必须带该引用,不能用占位内容绕过门禁。 +- 决策:game-chat 为 Supervisor ready 输出、四阶段专业 Agent `final-reply` 和每条后端批准公开的 Runtime 输出分配稳定消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到项目聊天。专业回复只接受 `art-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed`;`art-asset-plan` 不属于五分钟首版阶段,其回复不进入 game-chat 项目聊天。tool-plan 与流式半成品不进入聊天。Runtime 事件由 Rust 在写事件时生成唯一 `eventId` 和可选 `publicText`,前端只消费这两个字段,不重新解释 `summary / detail`;无公开投影的 legacy、Provider、Runner、tool payload、路径、指纹、哈希和敏感字段不得写 conversation。`append_local_conversation_message` 通过顶层 `messageId` 使用后端幂等追加。普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke` 和 `preview.validate`,不再调度 `publish-strategy` / `publish-package`,GUI / CLI 仍执行完整 DAG。`agent.schedule_ready` 同样必须按当前父 Run 的持久 source/profile 走 source-aware scheduler,不得回退通用完整 DAG;`task.list` 必须隐藏两个发布节点及其 ready/count 投影,`agent.delegate` 必须根据 root binding 拒绝直接委派这两个节点,所有绑定读取错误均失败关闭。 +- 单轮语义:Runtime 的 `loopIteration` 是同一父 Run 内的 Provider / 工具循环,不是用户可见的游戏版本轮次;game-chat 的进度卡、当前工作和最新状态统一显示“本轮”,不显示根或专业 Agent 的内部“第 N 轮”,完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,首版快车道改为四阶段 `x/4`,终态后移除运行中进度卡。`preview-playtest` 与全部完成门满足且非验证屏障清零后,Runtime 以确定性最终回复完成结构化计划并立即结算父 Run,不再把“是否继续”交给下一次 Provider tool-plan。 +- 美术资源门禁:live10 实测透明 `icon-spritesheet` 的生成和后处理超过 `300` 秒,不能纳入 game-chat 五分钟首版。game-chat 改为由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,`code-prototype` 必须把它显著用于用户可见的主要背景、玩家和目标;未配置 External Editor API、生成或登记失败、文件缺失、HTML 未引用或可见画面未使用均拒绝完成。普通 GUI / CLI autonomous 继续执行完整 DAG,并以正式透明 `assets/art-spritesheet.png` 及其真实引用作为原有美术硬门。 - 验证:`agentRuntimeModel.test.ts` 10 项通过;新增 Rust source allowlist、game-chat parent completion 与 Canvas spritesheet reference 合同测试通过;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。两项既有 Windows `os error 32` 文件锁竞态仍单独记录,未归因于本次改动。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 @@ -5888,14 +5888,14 @@ - 决策:game-chat 的 client-owned Runner 在活动任务期间禁止关闭客户端;关闭前复用既有 durable idle 真相源,避免另建 UI busy 状态。用户明确暂停/取消并达到 idle 后再退出,不能靠重启后自动重放未知 Provider 结果。 - 决策:规范 Agent reasoning 默认由角色职责分层,显式 per-Agent patch 优先;配置状态对外展示实际 timing/retry,避免全局文件、per-Agent resolver 与历史 run snapshot 混淆。 -## 2026-08-01 game-chat 首版三任务快车道与受控兜底 +## 2026-08-01 game-chat 首版四阶段快车道与美术硬门 -- 决策:game-chat 首版只投影 `code-prototype`、`preview-readiness`、`preview-playtest` 三个阶段,页面进度显示 `x/3`;完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。 -- 预算:父 Run 接受请求后以 `240` 秒作为首版软预算;父 Run、所有 child Run、等待、回收和确定性验收共享 `300` 秒累计硬上限。软预算后不再扩展 Provider 规划,只能运行受控 fallback、`game.static_smoke` 和 `preview.validate`;硬上限未形成当前 revision 的通过证据时必须失败关闭,单轮确定性收束也必须复核累计时间,不能在上限后补写 completed。 +- 决策:game-chat 首版只投影 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,页面进度显示 `x/4`;四个专业 Agent 的安全 `final-reply` 均逐条进入项目聊天,`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。 +- 预算:父 Run 接受请求后以 `240` 秒作为首版软预算;父 Run、所有 child Run、等待、回收和确定性验收共享 `300` 秒累计硬上限。硬上限是从 root `bound_at` 计算的绝对 deadline,必须包住 Provider、图片生成、文件写入、静态检查、`preview.validate` 和 final-reply 的在途等待;超时先强制持久化 `failed` 终态,再清理 pending action、Provider batch、confirmation、recovery 和进程会话,不能留下 `needs-reconciliation` 悬空态。软预算后不再扩展 Provider 规划,只能运行受控 fallback、`game.static_smoke` 和 `preview.validate`;硬上限未形成当前 revision 的通过证据时必须失败关闭,单轮确定性收束也必须复核累计时间,不能在上限后补写 completed。 - Provider:首版最多一次 Provider 规划 / 写入请求,禁止同一首版自动传输重试、第二次 tool-plan 或无限 repair。Provider 结束后由 Runtime 按当前 revision 依次执行确定性静态 smoke 与浏览器试玩。 -- 兜底:fallback HTML 必须自包含、无远程依赖,从 `ready` 开始并真实绘制 Canvas,持续更新 `playable-web-game-state.v1`,提供键盘 / 触控、start / primary-action / restart 和胜负状态;primary-action 后可保持 `playing`,restart 后可稳定恢复 `ready | playing`,不得开始前固定 `lost` 或用固定失败充当完成。可保留 `../assets/art-spritesheet.png` 引用,但图片缺失不能阻止 Canvas fallback 正常运行。 -- 美术边界:game-chat 首版平台图集是非阻塞增强,确定性兜底只在 manifest 已登记且项目文件真实存在时设置图集 `src` 并通过 Canvas 使用,缺失时不发起 404;图集稍后登记后可刷新同一预览。普通 GUI / CLI autonomous 仍执行完整 DAG,配置 Editor API Key 时正式图集产物和 HTML 引用继续作为硬完成门,缺失即失败关闭。 +- 兜底:fallback HTML 必须自包含、无远程运行依赖,从 `ready` 开始并真实绘制 Canvas,持续更新 `playable-web-game-state.v1`,提供键盘 / 触控、start / primary-action / restart 和胜负状态;primary-action 后可保持 `playing`,restart 后可稳定恢复 `ready | playing`,不得开始前固定 `lost` 或用固定失败充当完成。fallback 只有在 `assets/art-spec.png` 已有效登记并真实存在时才能生成,而且必须把该平台图片显著绘制为主要背景、玩家和目标;不得以纯 Canvas 视觉绕过平台图片硬门。 +- 美术边界:live10 已证明正式透明 `icon-spritesheet` 的后处理无法稳定收进 `300` 秒。game-chat 首版必须配置可用的 External Editor API,由 `art-director` 发起一次平台 `images/generations` 并登记 `assets/art-spec.png`,再由 `code-prototype` 在用户可见画面中把该图片作为主要背景、玩家和目标真实加载和绘制。图片必须可完整解码,引用必须从 `game/index.html` 正确解析到登记路径;活动 Canvas 上同一资源至少包含一次背景级和两次实体级的可达 `drawImage`,隐藏 Canvas、诱饵路径和永不执行函数均不能过门。未配置 API,或生成、登记、文件、引用、可见使用任一缺失时都必须失败关闭,不得写 completed 或 `single_round_converged`。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG,不采用 game-chat 的 `art-spec.png` 快车道。 - Windows preview 稳定性:非阻塞 listener 接受连接后必须先把 accepted socket 恢复为阻塞模式,再有界读完拆分到达的请求头;完整响应 `flush + shutdown(Write)` 后执行短时有界 drain。Chromium speculative socket 导致的 `ConnectionAborted / ConnectionReset / Interrupted / TimedOut` 归为可继续监听的瞬时 accept 错误;单个中止连接不得令后续 `preview.validate` 复用或重建时连续得到 `net::ERR_SOCKET_NOT_CONNECTED`。 - Runtime 恢复确认:GUI 自动扫描 `agent.resume` 前必须先用只读方式判断是否存在可恢复任务或 durable recovery artifact;全新项目与已完全终态、无任何恢复工作的项目直接返回空结果,不弹出“恢复未完成 Runtime 任务”;一旦存在 task、retry、handoff、finalization、pending action 或 reconciliation 等可恢复工作,仍必须经过原 `agent.resume` policy 门禁,不得通过吞掉 policy error 绕过确认。 -- 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和三阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。 +- 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和四阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 87c635b38..8f26bcc0c 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -81,11 +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 输出、`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/3`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口(包括 `agent.schedule_ready`、`task.list` 结果驱动的直接 `agent.delegate`)均不得暴露或启动 `publish-strategy` / `publish-package`,同时 GUI / CLI autonomous 必须继续执行完整发布 DAG;普通 GUI / CLI autonomous 在配置 External Editor API 时,`code-prototype` 的 `game/index.html` 实际引用已由 Canvas 登记的 `assets/art-spritesheet.png`,缺少登记、文件或引用必须失败关闭,game-chat 首版则只在登记与文件均存在时加载图集。对应定向测试至少包括: +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 首版快车道另有独立三阶段口径:只显示 `code-prototype`、`preview-readiness`、`preview-playtest` 的 `x/3`,不把完整 DAG 或内部 loop 计入分母。父 Run 与全部 child Run 共用 240 秒软预算和 300 秒累计硬上限;首版最多一次 Provider 规划 / 写入请求,软预算后只能运行确定性的本地 fallback、`game.static_smoke`、`preview.validate`,硬上限内未通过完成门必须失败,证据在上限后到齐也不得写 `single_round_converged`。fallback 模板必须自包含、可推进、可重开,从 `ready` 开始并避免固定 `lost`;平台图集在 game-chat 首版是非阻塞增强,只有登记和文件都存在时才设置 `src` 并 `drawImage`,缺失时不发起 404、继续使用 Canvas fallback。普通 GUI / CLI 继续完整 DAG;配置 Editor API Key 时正式 GUI / CLI 美术图集与 HTML 引用仍是硬门。对应定向测试至少包括: +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 GUI 恢复还要覆盖两类竞态:root Runtime 先终态、manifest 三阶段后终态时,必须等到三任务最终状态后仅持久化一条 `【Supervisor 阶段记录】`;页面初始 hydration 直接读到真实终态时也要补写缺失记录,但不得把 `idle` 当作完成。同时,GUI 启动的 `agent.resume` 自动扫描必须先做只读恢复工作预检:新项目或无 task / retry / handoff / finalization / pending / reconciliation 工作的已终态项目不弹确认,存在任何 durable recovery artifact 则仍必须命中 `agent.resume` policy。 +game-chat GUI 恢复还要覆盖两类竞态:root Runtime 先终态、manifest 四阶段后终态时,必须等到四任务最终状态后仅持久化一条 `【Supervisor 阶段记录】`;页面初始 hydration 直接读到真实终态时也要补写缺失记录,但不得把 `idle` 当作完成。同时,GUI 启动的 `agent.resume` 自动扫描必须先做只读恢复工作预检:新项目或无 task / retry / handoff / finalization / pending / reconciliation 工作的已终态项目不弹确认,存在任何 durable recovery artifact 则仍必须命中 `agent.resume` policy。 ```bash npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 267b55a00..3abf6cb6c 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 终态后,客户端等待 `code-prototype / preview-readiness / preview-playtest` 三项快车道任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/3` 提前归档,要暂存终态 Runtime 并在 manifest 刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 +- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待 `art-director / code-prototype / preview-readiness / preview-playtest` 四项快车道任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。`art-asset-plan` 不属于五分钟首版阶段,不阻塞阶段记录。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/4` 提前归档,要暂存终态 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 继续以稳定身份显示;`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 的 Supervisor `ready` response stream 继续以稳定身份显示;`art-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 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/3`(详见 2026-08-01 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 -- 平台美术资源:本条硬门仅适用于普通 GUI / CLI autonomous。配置 External Editor API 时,普通 GUI / CLI 的 `code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 实际引用该路径;缺少登记、文件或引用均 fail-closed。game-chat 首版按下方 2026-08-01 非阻塞例外执行,确定性兜底只在图集已登记且文件真实存在时加载该路径。 +- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版快车道只按四阶段显示 `x/4`(详见 2026-08-01 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 +- 平台美术资源:live10 实测正式透明 `icon-spritesheet` 的生成与后处理超过 `300` 秒,因此 game-chat 五分钟首版不运行 `art-asset-plan` 图集链路。它必须配置可用的 External Editor API,由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,再由 `code-prototype` 在 `game/index.html` 的用户可见画面中把该图片显著用于主要背景、玩家和目标;未配置 API,或缺少生成、登记、文件、引用、可见使用任一证据时均 fail-closed。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG,不采用该快车道。 - 验证:前端运行时模型定向测试、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 首版只展示 `code-prototype`、`preview-readiness`、`preview-playtest` 三个阶段,进度统一显示为 `x/3`,不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。 -- 时间预算:从 game-chat 父 Run 接受用户请求开始,首版可玩版本使用 `240` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享 `300` 秒硬上限,不能把硬上限拆成每个 Agent 独立计时。达到软预算后只允许进入确定性的本地兜底、静态 smoke 和浏览器试玩;达到硬上限仍未通过完成门必须失败关闭。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`,不得把超时伪装成 completed。 +- 首版任务边界:game-chat 首版只展示 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,进度统一显示为 `x/4`;`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 次数:首版快车道最多执行一次 Provider 首版规划 / 写入请求;后续不再请求第二次 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`,也不得通过固定失败冒充试玩通过。只有 manifest 已登记且项目文件真实存在时,模板才给隐藏图片设置 `../assets/art-spritesheet.png` 的 `src` 并通过 Canvas `drawImage` 使用;缺少登记或文件时不得发起必然 404 的请求,必须继续使用本地 Canvas fallback。 -- 平台图集:game-chat 首版的平台图集属于非阻塞增强。External Editor API 或图集生成不可用时,先交付上述可玩本地视觉;图集稍后登记后可在同一预览 origin 刷新,不得阻塞首版 smoke / playtest。普通 GUI / CLI autonomous 仍执行完整 DAG;配置 Editor API Key 时,GUI / CLI 的正式美术产物与 `game/index.html` 图集引用继续是硬完成门,缺少登记、文件或引用必须失败关闭。 -- 关联验收:快车道必须分别验证 `x/3` 投影、240 / 300 秒累计预算、单次 Provider 请求、兜底模板可推进 / 可重开 / 非固定失败、当前 revision 的静态 smoke 与浏览器试玩;这些规则不改变普通 GUI / CLI 的完整任务图和美术硬门。 +- 可玩兜底:软预算或首版 Provider 无法及时完成时,可以生成完整、自包含、无远程运行依赖的中文 HTML 模板。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 持续推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,也不得通过固定失败冒充试玩通过。模板只有在 `assets/art-spec.png` 已经完成有效登记并真实存在时才允许生成,且必须把该平台图片显著绘制为主要背景、玩家和目标;未配置 External Editor API、图片生成失败或缺少有效登记时直接失败关闭,不得生成纯几何首版。 +- 平台图片:live10 已证明透明 `icon-spritesheet` 后处理无法稳定收进 `300` 秒。game-chat 由 `art-director` 在首版预算内发起一次平台 `images/generations`,生成并登记 `assets/art-spec.png`;图片必须完整解码,`game/index.html` 的引用必须正确解析到该登记路径,且同一资源必须在非隐藏活动 Canvas 的可达执行路径中至少完成一次背景级和两次实体级 `drawImage`。`code-prototype` 必须在用户可见游戏画面中把它作为主要背景、玩家和目标真实加载和绘制。未配置 API,或生成、登记、文件存在、HTML 引用、可见使用任一缺失时,都必须在父 Run 的 `300` 秒累计硬上限内失败关闭,绝不写入 completed、`single_round_converged` 或其它成功结论。首版仍只在 `preview-playtest` 后单轮收束,不进入发布节点。普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。 +- 关联验收:快车道必须分别验证 `x/4` 投影、四个专业 Agent 安全 final-reply 逐条入聊天且排除 `art-asset-plan`、240 / 300 秒累计预算、单次 `images/generations`、External Editor API 缺失时失败关闭、`assets/art-spec.png` 生成 / 登记 / 文件 / 引用 / 主要背景与玩家及目标可见使用硬门,以及当前 revision 的静态 smoke 与浏览器试玩;这些规则不改变普通 GUI / CLI 的完整任务图和透明图集硬门。 ## Runtime 边界