diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 0e393c7ce..da05c1af0 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -30,6 +30,10 @@ const tauriHandlerSource = fs.readFileSync( new URL('../src-tauri/src/main.rs', import.meta.url), 'utf8', ); +const tauriRustSource = readSourceTree( + new URL('../src-tauri/src/', import.meta.url), + '.rs', +); const sharedContractSource = fs.readFileSync( new URL( '../../../packages/shared/src/contracts/gameCreationApp.ts', @@ -73,6 +77,26 @@ function collectFiles(path) { return []; } +function readSourceTree(path, extension) { + const stat = fs.statSync(path); + if (stat.isDirectory()) { + return fs + .readdirSync(path, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => + readSourceTree( + new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path), + extension, + ), + ) + .join('\n'); + } + if (pathnameExtension(path.pathname) !== extension) { + return ''; + } + return fs.readFileSync(path, 'utf8'); +} + function pathnameExtension(pathname) { const index = pathname.lastIndexOf('.'); return index === -1 ? '' : pathname.slice(index); @@ -299,7 +323,7 @@ assertCommandNamesSubset( assertCommandNamesSubset( 'AI game creator shell Tauri command implementation', parseTauriHandlerCommandNames(tauriHandlerSource), - parseRustFunctionNames(tauriHandlerSource), + parseRustFunctionNames(tauriRustSource), ); assertCommandNamesSubset( @@ -481,11 +505,6 @@ for (const snippet of [ } } -const tauriMainSource = fs.readFileSync( - new URL('../src-tauri/src/main.rs', import.meta.url), - 'utf8', -); - for (const snippet of [ 'const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"', 'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"', @@ -513,7 +532,7 @@ for (const snippet of [ '"command.auto"', 'GameCreationAppPermission::Auto', ]) { - if (!tauriMainSource.includes(snippet)) { + if (!tauriRustSource.includes(snippet)) { throw new Error( `AI game creator shell developer window guardrail drifted: ${snippet}`, ); @@ -524,7 +543,7 @@ for (const snippet of [ 'open_developer_window(app)?;', 'tauri::WebviewWindowBuilder::new(app, "developer"', ]) { - if (tauriMainSource.includes(snippet)) { + if (tauriRustSource.includes(snippet)) { throw new Error( `AI game creator shell must not auto-open developer windows: ${snippet}`, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs new file mode 100644 index 000000000..e7623bcbf --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -0,0 +1,3889 @@ +use super::*; + +pub(crate) async fn generate_local_game_draft_at( + root: &Path, + prompt: &str, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("创作想法不能为空".to_string()); + } + + init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; + let short_memory = read_optional_text(&root.join("memory/session.md"))?; + let long_memory = read_optional_text(&root.join("memory/project.md"))?; + let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; + let conversation_context = render_local_conversation_prompt_context(root, None)?; + let short_memory = append_prompt_context(&conversation_context, &short_memory); + let asset_context = render_local_asset_prompt_context(root)?; + let long_memory = append_prompt_context(&asset_context, &long_memory); + emit_agent_progress( + progress, + "llm.planner", + "Planner 正在调用 LLM 整理规格和专业组分工", + ); + let app_config = load_game_creator_app_config()?; + let loop_result = run_game_creator_agent_loop_at( + root, + &app_config, + prompt, + &short_memory, + &long_memory, + &project_blackboard, + progress, + ) + .await?; + let mut loop_result = loop_result; + emit_agent_progress( + progress, + "artifact.write", + "ArtifactWriter 正在写入本地代码、数值、美术、音乐和发布草案", + ); + let mut result = write_local_game_draft_at(root, prompt, &loop_result.draft)?; + append_local_artifact_write_step(root, prompt, &mut loop_result)?; + emit_agent_progress( + progress, + "playtest.static_smoke", + "Playtest 正在运行 game.static_smoke 自检", + ); + append_static_smoke_step(root, prompt, &mut loop_result)?; + append_agent_loop_log(root, &loop_result)?; + emit_agent_progress( + progress, + "agent.complete", + "Agent loop 已通过 Evaluator 和静态自检,正在启动本地预览", + ); + result.manifest = read_manifest_for_project(root)?; + Ok(result) +} + +pub(crate) fn write_local_game_draft_at( + root: &Path, + prompt: &str, + draft: &LlmGameDraft, +) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("创作想法不能为空".to_string()); + } + validate_llm_game_draft(prompt, draft)?; + init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; + let checkpoint = create_local_project_checkpoint_at(root)?; + let timestamp = unix_timestamp(); + let title = draft.title.trim(); + let handoff_summary = draft.handoff_summary.trim(); + let design_path = root.join("game/game_design.md"); + let balance_path = root.join("game/balance.json"); + let art_manifest_path = root.join("assets/manifest.art.json"); + let audio_manifest_path = root.join("assets/manifest.audio.json"); + let publish_readme_path = root.join("exports/README.md"); + let agent_log_path = root.join(".agent/logs/agent.log"); + let short_memory_path = root.join("memory/session.md"); + let long_memory_path = root.join("memory/project.md"); + let game_index_path = root.join("game/index.html"); + + append_markdown_entry( + &short_memory_path, + "# 短期记忆\n\n", + &format!("- {timestamp}: {prompt}\n"), + "写入短期记忆失败", + )?; + append_markdown_entry( + &long_memory_path, + "# 项目长期记忆\n\n## 当前约束\n\n- Web 小游戏原型\n- 本地 HTTP 预览\n\n## 创作目标记录\n\n", + &format!("- {timestamp}: {prompt}\n"), + "写入长期记忆失败", + )?; + fs::write( + &design_path, + format!( + "# 游戏设计草案\n\n## 原始想法\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n## LLM 生成草案\n\n{}\n", + draft.design_markdown.trim() + ), + ) + .map_err(|error| format!("写入游戏设计失败:{}: {error}", design_path.display()))?; + fs::write( + &balance_path, + serde_json::to_string_pretty(&draft.balance) + .map_err(|error| format!("生成数值配置失败:{error}"))?, + ) + .map_err(|error| format!("写入数值配置失败:{}: {error}", balance_path.display()))?; + fs::write( + &art_manifest_path, + serde_json::to_string_pretty(&draft.art_manifest) + .map_err(|error| format!("生成美术清单失败:{error}"))?, + ) + .map_err(|error| format!("写入美术清单失败:{}: {error}", art_manifest_path.display()))?; + fs::write( + &audio_manifest_path, + serde_json::to_string_pretty(&draft.audio_manifest) + .map_err(|error| format!("生成音乐音效清单失败:{error}"))?, + ) + .map_err(|error| { + format!( + "写入音乐音效清单失败:{}: {error}", + audio_manifest_path.display() + ) + })?; + fs::write( + &publish_readme_path, + format!( + "# 发布包装草案\n\n## 标题\n\n{title}\n\n## 简介\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n{}\n", + draft.publish_readme.trim() + ), + ) + .map_err(|error| { + format!( + "写入发布包装草案失败:{}: {error}", + publish_readme_path.display() + ) + })?; + + fs::write(&game_index_path, draft.game_html.trim()) + .map_err(|error| format!("写入游戏入口失败:{}: {error}", game_index_path.display()))?; + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&agent_log_path) + .and_then(|mut file| { + file.write_all( + format!("{timestamp} game.generate_draft llm\n{handoff_summary}\n").as_bytes(), + ) + }) + .map_err(|error| format!("写入 Agent 日志失败:{}: {error}", agent_log_path.display()))?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "game.generate_draft", + "goal": prompt, + "title": title, + "paths": [ + "memory/session.md", + "memory/project.md", + "game/game_design.md", + "game/balance.json", + "assets/manifest.art.json", + "assets/manifest.audio.json", + "exports/README.md", + "game/index.html", + ], + }), + )?; + let manifest = record_draft_task_progress(root, prompt, timestamp, &agent_log_path)?; + let diff = diff_local_project_checkpoint_at(root, &checkpoint.checkpoint_id)?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.diff", + "checkpointId": checkpoint.checkpoint_id, + "added": diff.added.len(), + "changed": diff.changed.len(), + "deleted": diff.deleted.len(), + }), + )?; + + Ok(GenerateLocalGameDraftResult { + project_path: root.to_string_lossy().into_owned(), + game_index_path: game_index_path.to_string_lossy().into_owned(), + design_path: design_path.to_string_lossy().into_owned(), + short_memory_path: short_memory_path.to_string_lossy().into_owned(), + long_memory_path: long_memory_path.to_string_lossy().into_owned(), + manifest, + }) +} + +pub(crate) struct AgentProgressEmitter<'a> { + app: &'a tauri::AppHandle, + project_path: String, +} + +impl<'a> AgentProgressEmitter<'a> { + pub(crate) fn new(app: &'a tauri::AppHandle, project_path: &str) -> Self { + Self { + app, + project_path: project_path.to_string(), + } + } + + fn emit(&self, stage: &str, message: &str) { + let _ = self.app.emit( + "game-creator-agent-progress", + GameCreatorAgentProgressEvent { + project_path: self.project_path.clone(), + stage: stage.to_string(), + message: message.to_string(), + }, + ); + } +} + +pub(crate) fn emit_agent_progress( + progress: Option<&AgentProgressEmitter<'_>>, + stage: &str, + message: &str, +) { + if let Some(progress) = progress { + progress.emit(stage, message); + } +} + +#[cfg(test)] +pub(crate) async fn request_llm_game_draft_with_client( + client: &LlmClient, + prompt: &str, + short_memory: &str, + long_memory: &str, +) -> Result { + let llm = GameCreatorLlmConfig::default(); + request_generator_game_draft_with_client( + client, + &llm, + prompt, + short_memory, + long_memory, + "", + "", + "", + "", + "", + None, + ) + .await +} + +pub(crate) async fn run_game_creator_agent_loop_at( + root: &Path, + app_config: &GameCreatorAppConfig, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Result { + let spec_path = root.join(".agent/spec.md"); + let findings_path = root.join(".agent/findings.md"); + let run_id = format!("game-generate-draft-{}", unix_millis()); + let mut steps = Vec::new(); + let planner_llm = resolve_game_creator_llm_config_for_agent(app_config, "planner"); + let planner_client = + build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?; + let planner_spec = request_planner_spec_with_client( + &planner_client, + &planner_llm, + prompt, + short_memory, + long_memory, + project_blackboard, + ) + .await + .map(|spec| render_planner_spec(prompt, &spec))?; + fs::write(&spec_path, &planner_spec) + .map_err(|error| format!("写入 Planner 规格失败:{}: {error}", spec_path.display()))?; + emit_agent_progress(progress, "llm.planner.done", "Planner 规格已生成"); + steps.push(with_task_context( + agent_trace_step( + 0, + "Planner", + "completed", + &[ + "memory/session.md", + "memory/project.md", + PROJECT_BLACKBOARD_MEMORY_PATH, + ".agent/conversations/project.jsonl", + ".agent/conversations/agents/", + ".agent/manifest.json", + ], + &[".agent/spec.md"], + "完成玩法规格和专业组分工", + "llm.chat.planner", + ), + "design", + "Director", + Some("design-director"), + "planning", + )); + + let mut latest_findings = + render_evaluator_findings(0, &["暂无上一轮问题,Generator 可开始首轮实现。"]); + fs::write(&findings_path, &latest_findings).map_err(|error| { + format!( + "写入 Evaluator 结果失败:{}: {error}", + findings_path.display() + ) + })?; + steps.push(agent_trace_step( + 0, + "Evaluator", + "waiting", + &[], + &[".agent/findings.md"], + "初始化评估反馈文件", + "file.write.findings", + )); + write_agent_run_trace(root, &run_id, prompt, "running", 0, &steps, None)?; + + let mut last_error = "Evaluator 未产出可用结果".to_string(); + for pass in 1..=GAME_CREATOR_AGENT_LOOP_MAX_PASSES { + emit_agent_progress( + progress, + "agent.orchestrator", + &format!("Orchestrator 正在规划第 {pass} 轮任务图"), + ); + let spec_markdown = read_optional_text(&spec_path)?; + let findings_markdown = read_optional_text(&findings_path)?; + let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; + let agenda = write_agent_pass_agenda(root, pass, &findings_markdown)?; + steps.push(agent_trace_step_owned( + pass, + "Orchestrator", + "completed", + vec![ + ".agent/spec.md".to_string(), + ".agent/findings.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + ".agent/manifest.json".to_string(), + ], + vec![ + agenda.relative_path.clone(), + agenda.task_graph_relative_path.clone(), + ], + &format!( + "{};waves={};repairFocus={};repairRoutes={};carried={}", + agenda.summary, + agenda.dependency_waves.len(), + agenda.repair_focus.len(), + agenda.repair_routes.len(), + agenda.carried_task_ids.len() + ), + "agent.task_graph.plan_pass", + )); + let group_briefs = match request_agent_group_briefs_with_client( + root, + app_config, + prompt, + short_memory, + long_memory, + &project_blackboard, + &spec_markdown, + &findings_markdown, + &agenda, + pass, + ) + .await + { + Ok(briefs) => briefs, + Err(error) => { + let issues = vec![format!("专业组协作输出不可用:{error}")]; + steps.push(agent_trace_step( + pass, + "专业组协作", + "failed", + &[".agent/spec.md", ".agent/findings.md"], + &[], + &issues[0], + "agent.role.brief", + )); + latest_findings = render_evaluator_findings(pass, &issues); + fs::write(&findings_path, &latest_findings).map_err(|write_error| { + format!( + "写入 Evaluator 结果失败:{}: {write_error}", + findings_path.display() + ) + })?; + steps.push(with_task_context( + agent_trace_step( + pass, + "Evaluator", + "needs-revision", + &[], + &[".agent/findings.md"], + "记录专业组协作失败原因", + "file.write.findings", + ), + "code", + "Review", + Some("quality-review"), + "evaluation", + )); + last_error = issues.join(";"); + write_agent_run_trace(root, &run_id, prompt, "needs-revision", pass, &steps, None)?; + continue; + } + }; + emit_agent_progress( + progress, + "agent.role_briefs", + &format!("6 组角色 brief 已完成,第 {pass} 轮交给 Generator"), + ); + let platform_art_step = + maybe_generate_platform_art_asset_step(root, prompt, &group_briefs, pass, progress) + .await; + append_group_brief_steps(root, pass, &agenda.relative_path, &group_briefs, &mut steps); + if let Some(step) = platform_art_step { + steps.push(step); + } + let group_briefs_markdown = append_prompt_context( + &render_local_conversation_prompt_context(root, Some("*"))?, + &render_agent_group_briefs_context(&group_briefs), + ); + let group_briefs_markdown = append_prompt_context( + &group_briefs_markdown, + &render_local_asset_prompt_context(root)?, + ); + emit_agent_progress( + progress, + "llm.generator", + &format!("Generator 正在调用 LLM 生成第 {pass} 轮可运行草案"), + ); + let generator_llm = resolve_game_creator_llm_config_for_agent(app_config, "generator"); + let generator_client = + build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?; + match request_generator_game_draft_with_client( + &generator_client, + &generator_llm, + prompt, + short_memory, + long_memory, + &project_blackboard, + &spec_markdown, + &findings_markdown, + &group_briefs_markdown, + &read_optional_text(&root.join(&agenda.relative_path))?, + progress, + ) + .await + { + Ok(draft) => { + emit_agent_progress( + progress, + "llm.generator.done", + &format!("Generator 第 {pass} 轮草案已返回,Evaluator 开始质量评审"), + ); + let pass_artifacts = write_agent_pass_artifacts(root, pass, &draft)?; + let mut generator_input_paths = vec![ + "memory/session.md".to_string(), + "memory/project.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + ".agent/conversations/project.jsonl".to_string(), + ".agent/conversations/agents/".to_string(), + ".agent/manifest.json".to_string(), + ".agent/spec.md".to_string(), + ".agent/findings.md".to_string(), + agenda.relative_path.clone(), + agenda.task_graph_relative_path.clone(), + ]; + generator_input_paths + .extend(group_briefs.iter().map(|brief| brief.relative_path.clone())); + steps.push(agent_trace_step_owned( + pass, + "Generator", + "completed", + generator_input_paths, + vec![pass_artifacts.draft_json.clone()], + "生成结构化游戏草案", + "llm.chat.generator", + )); + append_collaboration_steps(pass, &draft, &pass_artifacts, &mut steps); + let issues = evaluate_game_draft(prompt, &draft); + latest_findings = render_evaluator_findings(pass, &issues); + fs::write(&findings_path, &latest_findings).map_err(|error| { + format!( + "写入 Evaluator 结果失败:{}: {error}", + findings_path.display() + ) + })?; + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "Evaluator", + if issues.is_empty() { + "passed" + } else { + "needs-revision" + }, + vec![ + pass_artifacts.game_html.clone(), + pass_artifacts.design_markdown.clone(), + pass_artifacts.balance_json.clone(), + pass_artifacts.art_manifest_json.clone(), + pass_artifacts.audio_manifest_json.clone(), + pass_artifacts.publish_readme.clone(), + ], + vec![".agent/findings.md".to_string()], + if issues.is_empty() { + "质量评审通过:玩法、资产、数值、程序和发布包装可进入预览试玩" + } else { + "质量评审发现问题,要求下一轮 Generator 修复" + }, + "evaluator.quality_review", + ), + "code", + "Review", + Some("quality-review"), + "evaluation", + )); + if issues.is_empty() { + emit_agent_progress( + progress, + "evaluator.passed", + &format!("Evaluator 第 {pass} 轮质量评审通过"), + ); + append_agent_success_memories(root, pass, &draft, &group_briefs)?; + write_agent_run_trace(root, &run_id, prompt, "passed", pass, &steps, None)?; + return Ok(GameCreatorAgentLoopResult { + run_id, + draft, + spec_markdown, + findings_markdown: latest_findings, + passes: pass, + steps, + }); + } + last_error = issues.join(";"); + emit_agent_progress( + progress, + "evaluator.needs_revision", + &format!("Evaluator 第 {pass} 轮要求返工:{last_error}"), + ); + write_agent_run_trace(root, &run_id, prompt, "needs-revision", pass, &steps, None)?; + } + Err(error) => { + let issues = vec![format!("Generator 输出不可用:{error}")]; + steps.push(agent_trace_step( + pass, + "Generator", + "failed", + &[".agent/spec.md", ".agent/findings.md"], + &[], + &issues[0], + "llm.chat.generator", + )); + latest_findings = render_evaluator_findings(pass, &issues); + fs::write(&findings_path, &latest_findings).map_err(|write_error| { + format!( + "写入 Evaluator 结果失败:{}: {write_error}", + findings_path.display() + ) + })?; + steps.push(with_task_context( + agent_trace_step( + pass, + "Evaluator", + "needs-revision", + &[], + &[".agent/findings.md"], + "记录 Generator 失败原因", + "file.write.findings", + ), + "code", + "Review", + Some("quality-review"), + "evaluation", + )); + last_error = issues.join(";"); + write_agent_run_trace(root, &run_id, prompt, "needs-revision", pass, &steps, None)?; + } + } + } + + let final_error = format!( + "Agent loop 已重试 {GAME_CREATOR_AGENT_LOOP_MAX_PASSES} 轮但仍未通过 Evaluator:{last_error}" + ); + write_agent_run_trace( + root, + &run_id, + prompt, + "failed", + GAME_CREATOR_AGENT_LOOP_MAX_PASSES, + &steps, + Some(&final_error), + )?; + Err(final_error) +} + +pub(crate) async fn request_planner_spec_with_client( + client: &LlmClient, + llm: &GameCreatorLlmConfig, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, +) -> Result { + let request = LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_planner_system_prompt()), + LlmMessage::user(game_creator_planner_user_prompt( + prompt, + short_memory, + long_memory, + project_blackboard, + )), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS); + let response = request_game_creator_llm_text(client, llm, request) + .await + .map_err(|error| format!("Planner 生成失败:{error}"))?; + let spec = strip_llm_thinking_blocks(response.text.as_str()); + if spec.is_empty() { + Err("Planner 未返回规格".to_string()) + } else { + Ok(spec) + } +} + +pub(crate) async fn request_generator_game_draft_with_client( + client: &LlmClient, + llm: &GameCreatorLlmConfig, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, + spec_markdown: &str, + findings_markdown: &str, + group_briefs_markdown: &str, + agenda_markdown: &str, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Result { + let system_prompt = game_creator_system_prompt(); + let user_prompt = game_creator_generator_user_prompt( + prompt, + short_memory, + long_memory, + project_blackboard, + spec_markdown, + findings_markdown, + group_briefs_markdown, + agenda_markdown, + ); + // deepseek-v4-pro 等推理模型偶发返回空 content(HTTP 200、completion_tokens=0, + // 参见 DeepSeek-V3 issue #1453)。这类空返回是上游瞬时故障,原样重发通常即可恢复, + // 因此仅对 EmptyResponse 最多重试 3 次(含首次共 4 次请求),其它错误不重试。 + const MAX_EMPTY_RETRIES: u32 = 3; + let mut empty_retries = 0u32; + let response = loop { + let request = LlmRunRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt.clone()), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); + match request_game_creator_llm_text(client, llm, request).await { + Ok(response) => break response, + Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { + empty_retries += 1; + eprintln!( + "llm.chat.generator.empty-response 重试 {empty_retries}/{MAX_EMPTY_RETRIES}(上游返回空 content,原样重发)" + ); + // 同步推送到 App 进度面板,便于在界面上看到重试(无需盯命令行)。 + emit_agent_progress( + progress, + "llm.generator.empty_retry", + &format!( + "Generator 收到空返回(DeepSeek 偶发),正在自动重试 {empty_retries}/{MAX_EMPTY_RETRIES}" + ), + ); + continue; + } + Err(error) => { + // 调用失败(含空返回重试耗尽)时,把本次输入连同错误一并落盘,便于复现定位(仅 debug 构建)。 + #[cfg(all(debug_assertions, not(test)))] + debug::persist_error_input(system_prompt, user_prompt.as_str(), &error.to_string()); + return Err(format!("LLM 生成失败:{error}")); + } + } + }; + // 先把原始返回落盘,再解析;解析失败(如输出截断)时仍能从仓库里拿到完整原文(仅 debug 构建)。 + #[cfg(all(debug_assertions, not(test)))] + debug::persist_snapshot(response.text.as_str()); + let content = strip_llm_thinking_blocks(response.text.as_str()); + parse_llm_game_draft_response(content.as_str()) +} + +pub(crate) async fn request_game_creator_llm_text( + client: &LlmClient, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, +) -> Result { + if llm.stream { + client.stream_run(request, |_| {}).await + } else { + client.run(request).await + } +} + +pub(crate) async fn request_agent_group_briefs_with_client( + root: &Path, + app_config: &GameCreatorAppConfig, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, + spec_markdown: &str, + findings_markdown: &str, + agenda: &AgentPassAgenda, + pass: u8, +) -> Result, String> { + let mut briefs = Vec::new(); + let mut completed_group_context = String::new(); + let agenda_markdown = read_optional_text(&root.join(&agenda.relative_path))?; + for definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + let mut role_briefs = Vec::new(); + let mut completed_role_context = String::new(); + for role_definition in definition.roles { + let agent_memory_relative_path = + agent_role_memory_relative_path(definition, *role_definition); + let mut should_run = agenda + .active_task_ids + .iter() + .any(|task_id| task_id == role_definition.task_id); + if !should_run { + if let Some((source_path, source_markdown)) = + read_previous_agent_role_brief(root, pass, definition, *role_definition)? + { + let markdown = render_carryover_role_brief( + definition, + *role_definition, + &source_path, + &source_markdown, + ); + let relative_path = write_agent_role_brief( + root, + pass, + definition, + *role_definition, + &markdown, + )?; + let role_brief = AgentRoleBrief { + group_definition: definition, + role_definition: *role_definition, + markdown, + relative_path, + memory_relative_path: agent_memory_relative_path, + status: "carried-over".to_string(), + tool_id: format!( + "agent.task_graph.carryover.{}.{}", + definition.id, role_definition.id + ), + summary: format!( + "沿用上一轮 {} / {} brief,未命中本轮修复范围", + definition.label, role_definition.role + ), + }; + completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); + role_briefs.push(role_brief); + continue; + } + should_run = true; + } + if !should_run { + continue; + } + let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?; + let agent_conversation_context = + render_local_conversation_prompt_context(root, Some(role_definition.task_id))?; + let role_short_memory = + append_prompt_context(&agent_conversation_context, short_memory); + let local_markdown = render_local_agent_role_brief( + definition, + *role_definition, + prompt, + &role_short_memory, + long_memory, + project_blackboard, + &agent_memory, + spec_markdown, + findings_markdown, + &agenda_markdown, + &completed_group_context, + &completed_role_context, + pass, + ); + let (markdown, tool_id, summary) = + if has_game_creator_agent_llm_override(app_config, role_definition.task_id) { + let markdown = request_agent_role_brief_with_config( + app_config, + role_definition.task_id, + &local_markdown, + ) + .await?; + ( + markdown, + format!("llm.chat.{}", role_definition.task_id), + format!( + "{} / {} 使用 agentLlm.{} 生成 brief", + definition.label, role_definition.role, role_definition.task_id + ), + ) + } else { + ( + local_markdown, + role_definition.tool_id.to_string(), + format!( + "本地编排生成 {} / {} brief", + definition.label, role_definition.role + ), + ) + }; + let relative_path = + write_agent_role_brief(root, pass, definition, *role_definition, &markdown)?; + let role_brief = AgentRoleBrief { + group_definition: definition, + role_definition: *role_definition, + markdown, + relative_path, + memory_relative_path: agent_memory_relative_path, + status: "completed".to_string(), + tool_id, + summary, + }; + completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); + role_briefs.push(role_brief); + } + let markdown = render_agent_group_brief_markdown(&role_briefs); + let relative_path = write_agent_group_brief(root, pass, definition, &markdown)?; + completed_group_context.push_str(&format!( + "## {} / {}\n\n{}\n\n", + definition.label, + definition.role, + markdown.trim() + )); + briefs.push(AgentGroupBrief { + definition, + markdown, + relative_path, + role_briefs, + }); + } + Ok(briefs) +} + +pub(crate) fn has_game_creator_agent_llm_override( + config: &GameCreatorAppConfig, + agent_id: &str, +) -> bool { + config + .agent_llm + .get(agent_id) + .is_some_and(|patch| !is_empty_game_creator_llm_patch(patch)) +} + +pub(crate) async fn request_agent_role_brief_with_config( + config: &GameCreatorAppConfig, + agent_id: &str, + local_markdown: &str, +) -> Result { + let llm = resolve_game_creator_llm_config_for_agent(config, agent_id); + let config_path = format!("agentLlm.{agent_id}"); + let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let request = LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_role_agent_system_prompt()), + LlmMessage::user(format!( + "请基于下面的本地上下文生成本角色的 Markdown brief。只返回 brief 正文,不要代码块。\n\n{}", + truncate_prompt_context(local_markdown) + )), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS); + let response = request_game_creator_llm_text(&client, &llm, request) + .await + .map_err(|error| format!("{config_path} 生成角色 brief 失败:{error}"))?; + let brief = strip_llm_thinking_blocks(response.text.as_str()); + if brief.is_empty() { + Err(format!("{config_path} 未返回角色 brief")) + } else { + Ok(brief) + } +} + +pub(crate) fn game_creator_role_agent_system_prompt() -> &'static str { + "你是 AI 游戏创作多智能体中的一个专业角色 agent。输出必须是简洁、可执行的 Markdown brief,服务于后续 Generator 生成可试玩 Web 小游戏原型。不要泄露密钥,不要输出 JSON,不要包裹代码块。" +} + +pub(crate) fn render_local_agent_role_brief( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, + agent_memory: &str, + spec_markdown: &str, + findings_markdown: &str, + agenda_markdown: &str, + completed_group_context: &str, + completed_role_context: &str, + pass: u8, +) -> String { + format!( + "本角色判断:pass {pass},{} / {} 负责 {},围绕用户需求“{}”给 Generator 提供约束。\n交付物:{}\n下游约束:{}\n验收风险:{}\n\n## Planner 摘要\n\n{}\n\n## Evaluator 反馈\n\n{}\n\n## 本轮 agenda\n\n{}\n\n## 记忆摘要\n\n短期:{}\n长期:{}\n项目黑板:{}\n本角色私有记忆:{}\n\n## 已完成上下文\n\n{}\n{}", + group_definition.label, + role_definition.role, + role_definition.task_id, + truncate_inline(prompt, 80), + role_definition.brief_path_name, + local_role_downstream_constraint(group_definition, role_definition), + local_role_acceptance_risk(group_definition, role_definition), + truncate_prompt_context(spec_markdown), + truncate_prompt_context(findings_markdown), + truncate_prompt_context(agenda_markdown), + truncate_inline(short_memory, 360), + truncate_inline(long_memory, 120), + truncate_inline(project_blackboard, 160), + truncate_inline(agent_memory, 160), + truncate_prompt_context(completed_group_context), + truncate_prompt_context(completed_role_context) + ) +} + +pub(crate) fn local_role_downstream_constraint( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, +) -> &'static str { + match (group_definition.id, role_definition.id) { + ("design", _) => "Generator 必须保留核心循环、输入、目标、失败、胜利和重开路径。", + ("balance", _) => "Generator 必须输出可被 game/balance.json 表达的速度、生命、难度和节奏参数。", + ("art", "asset") => "Generator 必须保留画板资源占位引用,并给出本地 fallback 视觉。", + ("art", _) => "Generator 必须保持像素风厨房主题和首版可读性。", + ("audio", _) => "Generator 必须给出 BGM 与核心交互音效清单,缺素材时使用静音 fallback。", + ("code", _) => "Generator 必须生成单文件 canvas HTML、输入监听、requestAnimationFrame 主循环和静态自检可通过的代码。", + ("publishing", _) => "Generator 必须输出标题、简介、标签、封面需求和试玩验收说明。", + _ => "Generator 必须响应本角色交付物并保持可试玩原型闭环。", + } +} + +pub(crate) fn local_role_acceptance_risk( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, +) -> &'static str { + match (group_definition.id, role_definition.id) { + ("code", _) => { + "缺 canvas 绘制、输入监听、胜负状态、重开或使用远程资源都会触发 Evaluator 返工。" + } + ("art", "asset") | ("audio", "sfx") => "未记录画板或本地资产占位会影响资产回流验收。", + ("publishing", _) => "缺发布包装会影响最终 exports/README.md 与运营组 handoff。", + _ => "输出空泛或偏离用户需求会增加 Generator 返工概率。", + } +} + +pub(crate) fn truncate_inline(value: &str, max_chars: usize) -> String { + let trimmed = value.split_whitespace().collect::>().join(" "); + let mut output = trimmed.chars().take(max_chars).collect::(); + if trimmed.chars().count() > max_chars { + output.push_str("..."); + } + output +} + +pub(crate) fn evaluate_game_draft(prompt: &str, draft: &LlmGameDraft) -> Vec { + let mut issues = Vec::new(); + if let Err(error) = validate_llm_game_draft(prompt, draft) { + issues.push(error); + } + let html = draft.game_html.to_ascii_lowercase(); + if !["keydown", "keyup", "pointer", "mousedown", "touch", "click"] + .iter() + .any(|needle| html.contains(needle)) + { + issues.push("gameHtml 缺少明确的键盘、鼠标或触摸输入监听".to_string()); + } + issues +} + +pub(crate) fn render_planner_spec(prompt: &str, spec: &str) -> String { + format!( + "# Planner Spec\n\n## 用户需求\n\n{}\n\n## 规格\n\n{}\n", + prompt.trim(), + spec.trim() + ) +} + +pub(crate) fn render_evaluator_findings(pass: u8, issues: &[impl AsRef]) -> String { + let issue_values = issues + .iter() + .map(|issue| issue.as_ref().trim()) + .filter(|issue| !issue.is_empty()) + .collect::>(); + let actionable_issues = issue_values + .iter() + .filter(|issue| !issue.contains("暂无上一轮问题")) + .map(|issue| (*issue).to_string()) + .collect::>(); + let mut output = format!( + "# Evaluator Findings\n\n- pass: {pass}\n- status: {}\n\n", + if issue_values.is_empty() { + "passed" + } else { + "needs-revision" + } + ); + if issue_values.is_empty() { + output + .push_str("## 结果\n\n- 本地静态验收通过:HTML 自包含、包含 canvas、主循环和输入。\n"); + } else { + output.push_str("## 问题\n\n"); + for issue in &issue_values { + output.push_str("- "); + output.push_str(issue); + output.push('\n'); + } + } + output.push_str("\n## Repair Routes\n\n```json\n"); + if actionable_issues.is_empty() { + output.push_str("[]"); + } else { + let routes = build_game_creation_seed_task_graph("AI 游戏创作") + .map(|graph| route_game_creation_repair_issues(&graph, &actionable_issues)) + .unwrap_or_default(); + match serde_json::to_string_pretty(&routes) { + Ok(payload) => output.push_str(&payload), + Err(_) => output.push_str("[]"), + } + } + output.push_str("\n```\n"); + output +} + +pub(crate) fn write_agent_pass_agenda( + root: &Path, + pass: u8, + findings_markdown: &str, +) -> Result { + let graph = build_game_creation_seed_task_graph("AI 游戏创作") + .map_err(|error| format!("构建 Agent 编排任务图失败:{error}"))?; + let pass_plan = plan_game_creation_agent_pass(&graph, pass, findings_markdown); + let repair_routes = pass_plan + .repair_routes + .iter() + .map(|route| GameCreationAgentRepairRouteTrace { + issue: route.issue.clone(), + task_ids: route.task_ids.clone(), + reason: route.reason.clone(), + }) + .collect::>(); + let relative_path = format!(".agent/passes/pass-{pass}/agenda.md"); + let markdown = render_agent_pass_agenda_markdown( + pass, + &pass_plan.mode, + &pass_plan.active_task_ids, + &pass_plan.carried_task_ids, + &pass_plan.dependency_waves, + &pass_plan.repair_focus, + &repair_routes, + ); + write_agent_pass_file(root, &relative_path, &markdown)?; + let task_graph_relative_path = format!(".agent/passes/pass-{pass}/task-graph.json"); + write_agent_pass_file( + root, + &task_graph_relative_path, + &render_agent_pass_task_graph_json( + pass, + &pass_plan.mode, + &pass_plan.summary, + &pass_plan.active_task_ids, + &pass_plan.carried_task_ids, + &pass_plan.dependency_waves, + &pass_plan.repair_focus, + &repair_routes, + )?, + )?; + Ok(AgentPassAgenda { + relative_path, + task_graph_relative_path, + active_task_ids: pass_plan.active_task_ids, + carried_task_ids: pass_plan.carried_task_ids, + dependency_waves: pass_plan.dependency_waves, + repair_focus: pass_plan.repair_focus, + repair_routes, + summary: pass_plan.summary, + }) +} + +pub(crate) fn render_agent_pass_agenda_markdown( + pass: u8, + mode: &str, + active_task_ids: &[String], + carried_task_ids: &[String], + dependency_waves: &[Vec], + issues: &[String], + repair_routes: &[GameCreationAgentRepairRouteTrace], +) -> String { + let mut output = format!( + "# Orchestrator Agenda\n\n- pass: {pass}\n- mode: {}\n- activeTasks: {}\n- carriedTasks: {}\n\n", + mode, + active_task_ids.join(", "), + if carried_task_ids.is_empty() { + "none".to_string() + } else { + carried_task_ids.join(", ") + } + ); + output.push_str("## Repair Focus\n\n"); + if issues.is_empty() { + output.push_str("- 首轮生成,所有组内角色参与。\n"); + } else { + for issue in issues { + output.push_str("- "); + output.push_str(issue); + output.push('\n'); + } + } + output.push_str("\n## Dependency Waves\n\n"); + for (index, wave) in dependency_waves.iter().enumerate() { + output.push_str(&format!( + "- wave {}: {}\n", + index + 1, + if wave.is_empty() { + "none".to_string() + } else { + wave.join(", ") + } + )); + } + output.push_str("\n## Repair Routes\n\n"); + if repair_routes.is_empty() { + output.push_str("- none\n"); + } else { + for route in repair_routes { + output.push_str(&format!( + "- issue: {}\n taskIds: {}\n reason: {}\n", + route.issue, + route.task_ids.join(", "), + route.reason + )); + } + } + output.push_str( + "\n## Rule\n\n- activeTasks 产出对应角色 brief。\n- carriedTasks 沿用上一轮 brief,避免无关角色重复返工。\n- dependencyWaves 是按任务依赖排序后的执行层级,Generator 必须优先服从较早 wave 的约束。\n", + ); + output +} + +pub(crate) fn render_agent_pass_task_graph_json( + pass: u8, + mode: &str, + summary: &str, + active_task_ids: &[String], + carried_task_ids: &[String], + dependency_waves: &[Vec], + issues: &[String], + repair_routes: &[GameCreationAgentRepairRouteTrace], +) -> Result { + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": "game-creator-agent-pass-task-graph.v1", + "pass": pass, + "mode": mode, + "summary": summary, + "activeTaskIds": active_task_ids, + "carriedTaskIds": carried_task_ids, + "repairFocus": issues, + "repairRoutes": repair_routes, + "dependencyWaves": dependency_waves, + })) + .map(|payload| format!("{payload}\n")) + .map_err(|error| format!("生成 Agent pass task graph 失败:{error}")) +} + +pub(crate) fn read_previous_agent_role_brief( + root: &Path, + pass: u8, + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, +) -> Result, String> { + if pass <= 1 { + return Ok(None); + } + let relative_path = format!( + ".agent/passes/pass-{}/groups/{}/{}", + pass - 1, + group_definition.id, + role_definition.brief_path_name + ); + match fs::read_to_string(root.join(&relative_path)) { + Ok(content) => Ok(Some((relative_path, content))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "读取上一轮角色 brief 失败:{relative_path}: {error}" + )), + } +} + +pub(crate) fn render_carryover_role_brief( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + source_path: &str, + source_markdown: &str, +) -> String { + format!( + "本角色判断:沿用上一轮 {} / {} 输出。\n交付物:{}\n下游约束:本轮未命中该任务,Generator 只在必要时读取此约束。\n验收风险:如果 Evaluator 后续命中该组,下一轮必须重新激活。\n\n## 上一轮 brief\n\n{}", + group_definition.label, + role_definition.role, + source_path, + source_markdown.trim() + ) +} + +pub(crate) fn write_agent_pass_artifacts( + root: &Path, + pass: u8, + draft: &LlmGameDraft, +) -> Result { + let relative_dir = format!(".agent/passes/pass-{pass}"); + let pass_dir = root.join(&relative_dir); + fs::create_dir_all(&pass_dir) + .map_err(|error| format!("创建 Agent pass 目录失败:{}: {error}", pass_dir.display()))?; + + let paths = AgentPassArtifactPaths { + draft_json: format!("{relative_dir}/draft.json"), + design_markdown: format!("{relative_dir}/design.md"), + balance_json: format!("{relative_dir}/balance.json"), + art_manifest_json: format!("{relative_dir}/manifest.art.json"), + audio_manifest_json: format!("{relative_dir}/manifest.audio.json"), + publish_readme: format!("{relative_dir}/README.md"), + game_html: format!("{relative_dir}/game.html"), + handoff_markdown: format!("{relative_dir}/handoff.md"), + }; + + write_agent_pass_file( + root, + &paths.draft_json, + &format!( + "{}\n", + serde_json::to_string_pretty(draft) + .map_err(|error| format!("序列化 Agent pass draft 失败:{error}"))? + ), + )?; + write_agent_pass_file( + root, + &paths.design_markdown, + &format!("# 策划组 / Gameplay\n\n{}\n", draft.design_markdown.trim()), + )?; + write_agent_pass_file( + root, + &paths.balance_json, + &format!( + "{}\n", + serde_json::to_string_pretty(&draft.balance) + .map_err(|error| format!("序列化 Agent pass 数值失败:{error}"))? + ), + )?; + write_agent_pass_file( + root, + &paths.art_manifest_json, + &format!( + "{}\n", + serde_json::to_string_pretty(&draft.art_manifest) + .map_err(|error| format!("序列化 Agent pass 美术清单失败:{error}"))? + ), + )?; + write_agent_pass_file( + root, + &paths.audio_manifest_json, + &format!( + "{}\n", + serde_json::to_string_pretty(&draft.audio_manifest) + .map_err(|error| format!("序列化 Agent pass 音乐清单失败:{error}"))? + ), + )?; + write_agent_pass_file( + root, + &paths.publish_readme, + &format!("# 运营组 / Publish\n\n{}\n", draft.publish_readme.trim()), + )?; + write_agent_pass_file(root, &paths.game_html, &draft.game_html)?; + write_agent_pass_file( + root, + &paths.handoff_markdown, + &render_agent_pass_handoff(pass, draft), + )?; + + Ok(paths) +} + +pub(crate) fn write_agent_pass_file( + root: &Path, + relative_path: &str, + content: &str, +) -> Result<(), String> { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent pass 文件目录失败:{}: {error}", + parent.display() + ) + })?; + } + fs::write(&path, content) + .map_err(|error| format!("写入 Agent pass 文件失败:{}: {error}", path.display())) +} + +pub(crate) fn write_agent_group_brief( + root: &Path, + pass: u8, + definition: AgentGroupDefinition, + markdown: &str, +) -> Result { + let relative_path = format!( + ".agent/passes/pass-{pass}/groups/{}", + definition.brief_path_name + ); + write_agent_pass_file( + root, + &relative_path, + &format!( + "# {} / {}\n\n{}\n", + definition.label, + definition.role, + markdown.trim() + ), + )?; + Ok(relative_path) +} + +pub(crate) fn write_agent_role_brief( + root: &Path, + pass: u8, + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + markdown: &str, +) -> Result { + let relative_path = format!( + ".agent/passes/pass-{pass}/groups/{}/{}", + group_definition.id, role_definition.brief_path_name + ); + write_agent_pass_file( + root, + &relative_path, + &format!( + "# {} / {}\n\n- task: {}\n\n{}\n", + group_definition.label, + role_definition.role, + role_definition.task_id, + markdown.trim() + ), + )?; + Ok(relative_path) +} + +pub(crate) fn agent_role_memory_relative_path( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, +) -> String { + format!( + "memory/agents/{}/{}", + group_definition.id, role_definition.brief_path_name + ) +} + +pub(crate) fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result { + for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + for role in group.roles { + if role.task_id == task_id { + return Ok(agent_role_memory_relative_path(group, *role)); + } + } + } + Err(format!("未知 Agent 任务:{task_id}")) +} + +pub(crate) fn append_agent_success_memories( + root: &Path, + pass: u8, + draft: &LlmGameDraft, + briefs: &[AgentGroupBrief], +) -> Result<(), String> { + let timestamp = unix_timestamp(); + let title = draft.title.trim(); + let mut blackboard_entry = format!( + "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n\n## 角色共享摘要\n\n" + ); + + for brief in briefs { + for role_brief in &brief.role_briefs { + blackboard_entry.push_str(&format!( + "- {} / {}:{};status={};brief={}\n", + role_brief.group_definition.label, + role_brief.role_definition.role, + role_brief.summary, + role_brief.status, + role_brief.relative_path + )); + let private_entry = format!( + "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- task:{}\n- status:{}\n- brief:{}\n- 摘要:{}\n", + role_brief.role_definition.task_id, + role_brief.status, + role_brief.relative_path, + role_brief.summary + ); + append_markdown_entry( + &root.join(&role_brief.memory_relative_path), + &format!( + "# Agent 私有记忆 - {} / {}\n\n", + role_brief.group_definition.label, role_brief.role_definition.role + ), + &private_entry, + "写入 Agent 私有记忆失败", + )?; + } + } + + append_markdown_entry( + &root.join(PROJECT_BLACKBOARD_MEMORY_PATH), + "# 项目黑板\n\n", + &blackboard_entry, + "写入项目黑板失败", + ) +} + +pub(crate) fn append_group_brief_steps( + root: &Path, + pass: u8, + agenda_relative_path: &str, + briefs: &[AgentGroupBrief], + steps: &mut Vec, +) { + let canvas_asset_media_types = project_canvas_asset_media_types(root); + for brief in briefs { + for role_brief in &brief.role_briefs { + let input_paths = vec![ + "memory/session.md".to_string(), + "memory/project.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + ".agent/conversations/project.jsonl".to_string(), + ".agent/conversations/agents/".to_string(), + role_brief.memory_relative_path.clone(), + ".agent/manifest.json".to_string(), + ".agent/spec.md".to_string(), + ".agent/findings.md".to_string(), + agenda_relative_path.to_string(), + ]; + let mut output_paths = vec![role_brief.relative_path.clone()]; + if role_brief.status == "completed" || role_brief.status == "carried-over" { + output_paths.push(role_brief.memory_relative_path.clone()); + output_paths.push(PROJECT_BLACKBOARD_MEMORY_PATH.to_string()); + } + let mut step = with_task_context( + agent_trace_step_owned( + pass, + &format!( + "{} / {}", + role_brief.group_definition.label, role_brief.role_definition.role + ), + &role_brief.status, + input_paths.clone(), + output_paths, + &role_brief.summary, + &role_brief.tool_id, + ), + role_brief.group_definition.id, + role_brief.role_definition.role, + Some(role_brief.role_definition.task_id), + "role-brief", + ); + if let Some(tool_call) = + suggested_canvas_tool_call(role_brief, &input_paths, &canvas_asset_media_types) + { + step.tool_calls.push(tool_call); + } + steps.push(step); + } + let role_paths = brief + .role_briefs + .iter() + .map(|role_brief| role_brief.relative_path.clone()) + .collect::>(); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + &format!("{} / GroupCoordinator", brief.definition.label), + "completed", + role_paths, + vec![brief.relative_path.clone()], + "汇总组内角色 brief,交给 Generator", + &format!("agent.group.aggregate.{}", brief.definition.id), + ), + brief.definition.id, + "GroupCoordinator", + None, + "group-aggregate", + )); + } +} + +pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec { + read_manifest_for_project(root) + .map(|manifest| { + manifest + .assets + .iter() + .filter(|asset| asset.source.kind == GameCreationAppAssetSourceKind::Canvas) + .map(|asset| asset.media_type.clone()) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn suggested_canvas_tool_call( + role_brief: &AgentRoleBrief, + input_paths: &[String], + canvas_asset_media_types: &[String], +) -> Option { + if role_brief.status != "completed" + || role_has_canvas_assets(role_brief, canvas_asset_media_types) + { + return None; + } + let tool_id = match ( + role_brief.group_definition.id, + role_brief.role_definition.id, + ) { + ("art", "asset") | ("audio", "sfx") => "agent.tool.suggest.canvas.project_sync", + _ => return None, + }; + Some(GameCreationAgentToolCallTrace { + tool_id: tool_id.to_string(), + status: "suggested".to_string(), + input_paths: input_paths.to_vec(), + output_paths: Vec::new(), + summary: "项目还没有对应类型的画板回流素材;建议用户确认 /sync-canvas-project <画板项目ID> 后同步画板资源到本地 assets/。" + .to_string(), + }) +} + +pub(crate) async fn maybe_generate_platform_art_asset_step( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + pass: u8, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Option { + if !needs_platform_art_asset_generation(root, briefs) || !editor_api_key_is_configured() { + return None; + } + emit_agent_progress( + progress, + "editor.image_generate", + "美术组正在通过平台 External Editor API 生成首版素材", + ); + let result = generate_platform_art_asset_at(root, prompt, briefs).await; + let (status, output_paths, summary) = match result { + Ok(generated) => ( + "completed", + vec![generated.asset.local_path.clone()], + format!( + "已通过平台 External Editor API 生成首版美术素材:{}", + generated.asset.local_path + ), + ), + Err(error) => ( + "failed", + Vec::new(), + format!("平台 External Editor API 生成首版美术素材失败:{error}"), + ), + }; + Some(with_task_context( + agent_trace_step_owned( + pass, + "美术组 / PlatformImageGenerate", + status, + vec![ + ".agent/spec.md".to_string(), + ".agent/manifest.json".to_string(), + ], + output_paths, + &summary, + "agent.tool.platform.editor_image_generate", + ), + "art", + "Asset", + Some("art-asset-plan"), + "asset-generation", + )) +} + +pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGroupBrief]) -> bool { + let canvas_asset_media_types = project_canvas_asset_media_types(root); + briefs.iter().any(|brief| { + brief.role_briefs.iter().any(|role_brief| { + role_brief.status == "completed" + && role_brief.group_definition.id == "art" + && role_brief.role_definition.id == "asset" + && !role_has_canvas_assets(role_brief, &canvas_asset_media_types) + }) + }) +} + +pub(crate) fn editor_api_key_is_configured() -> bool { + load_game_creator_app_config() + .ok() + .and_then(|config| trim_config_string(&config.editor_api.api_key)) + .is_some() +} + +pub(crate) fn role_has_canvas_assets(role_brief: &AgentRoleBrief, media_types: &[String]) -> bool { + match ( + role_brief.group_definition.id, + role_brief.role_definition.id, + ) { + ("art", "asset") => media_types.iter().any(|media_type| { + let media_type = media_type.as_str(); + media_type.starts_with("image/") + || media_type == "application/vnd.genarrative.image-sequence" + }), + ("audio", "sfx") => media_types + .iter() + .any(|media_type| media_type.starts_with("audio/")), + _ => false, + } +} + +pub(crate) async fn generate_platform_art_asset_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], +) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; + let api_base_url = resolve_canvas_sync_api_base_url(None)?; + let api_key = resolve_canvas_sync_api_key(None)?; + let client = reqwest::Client::new(); + let generation_prompt = build_platform_art_asset_prompt(prompt, briefs); + let response = client + .post(format!( + "{}/api/external/v1/editor/images/generations", + api_base_url + )) + .bearer_auth(&api_key) + .json(&serde_json::json!({ + "prompt": generation_prompt, + "aspectRatio": "1:1", + "imageSize": "1K", + "assetKind": "game-art", + "assetLabel": "AI 游戏首版美术素材", + })) + .send() + .await + .map_err(|error| format!("请求平台图片生成失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + } + let payload = response + .json::() + .await + .map_err(|error| format!("解析平台图片生成响应失败:{error}"))?; + let generated = payload.get("data").unwrap_or(&payload); + let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, generated) + .await? + .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; + let null = serde_json::Value::Null; + let resource = generated.get("resource").unwrap_or(&null); + let asset = generated.get("asset").unwrap_or(&null); + let resource_id = json_string_field(resource, "resourceId"); + let task_id = + json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId")); + let asset_object_id = json_string_field(generated, "assetObjectId") + .or_else(|| json_string_field(resource, "assetObjectId")) + .or_else(|| json_string_field(asset, "assetObjectId")); + let generated_prompt = json_string_field(generated, "actualPrompt") + .or_else(|| json_string_field(generated, "prompt")) + .or_else(|| json_string_field(resource, "actualPrompt")) + .or_else(|| json_string_field(resource, "prompt")); + let model = + json_string_field(generated, "model").or_else(|| json_string_field(resource, "model")); + let provider = json_string_field(generated, "provider") + .or_else(|| json_string_field(resource, "provider")); + let asset_kind = json_string_field(resource, "assetKind") + .or_else(|| json_string_field(asset, "assetKind")) + .unwrap_or_else(|| "game-art".to_string()); + let source_hint = json_string_field(generated, "objectKey") + .or_else(|| json_string_field(generated, "imageSrc")); + let extension = infer_file_extension(source_hint.as_deref(), &download.media_type); + let file_stem = resource_id + .as_deref() + .or(task_id.as_deref()) + .unwrap_or("platform-art"); + let local_path = format!( + "assets/canvas-generated/{}-{}.{}", + unix_millis(), + sanitize_file_name(file_stem), + extension + ); + let absolute_path = root.join(&local_path); + if let Some(parent) = absolute_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建平台生成素材目录失败:{}: {error}", parent.display()))?; + } + fs::write(&absolute_path, &download.bytes) + .map_err(|error| format!("写入平台生成素材失败:{}: {error}", absolute_path.display()))?; + let canvas_project_id = json_string_field(resource, "projectId") + .or_else(|| json_string_field(generated, "projectId")); + let registered = register_local_asset_entry( + root, + &local_path, + &asset_kind, + &download.media_type, + "platform-art", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id, + resource_id: resource_id.clone(), + asset_object_id: asset_object_id.clone(), + task_id: task_id.clone(), + prompt: generated_prompt.clone(), + model: model.clone(), + }, + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "canvas.asset_generate", + "assetId": registered.id.clone(), + "localPath": registered.local_path.clone(), + "resourceId": resource_id.clone(), + "assetObjectId": asset_object_id.clone(), + "taskId": task_id.clone(), + "model": model.clone(), + "provider": provider.clone(), + }), + )?; + Ok(GeneratedPlatformArtAsset { + asset: registered, + resource_id, + asset_object_id, + task_id, + model, + }) +} + +pub(crate) fn build_platform_art_asset_prompt(prompt: &str, briefs: &[AgentGroupBrief]) -> String { + let art_asset_brief = briefs + .iter() + .flat_map(|brief| brief.role_briefs.iter()) + .find(|role_brief| { + role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset" + }) + .map(|role_brief| role_brief.markdown.trim()) + .filter(|markdown| !markdown.is_empty()) + .unwrap_or("需要一张可直接用于 Web 小游戏首版原型的核心美术素材。"); + format!( + "为 Web 小游戏首版原型生成一张核心美术素材,适合放入本地 assets 并被 canvas 游戏直接引用。\n用户需求:{}\n美术资产 brief:{}", + truncate_inline(prompt, 240), + truncate_prompt_context(art_asset_brief) + ) +} + +pub(crate) fn render_agent_group_brief_markdown(role_briefs: &[AgentRoleBrief]) -> String { + let mut output = String::new(); + for role_brief in role_briefs { + output.push_str(&format!( + "## {} / {}\n\n- task: {}\n- artifact: {}\n\n{}\n\n", + role_brief.group_definition.label, + role_brief.role_definition.role, + role_brief.role_definition.task_id, + role_brief.relative_path, + role_brief.markdown.trim() + )); + } + output +} + +pub(crate) fn render_agent_role_brief_context(role_brief: &AgentRoleBrief) -> String { + format!( + "## {} / {}\n\n{}\n\n", + role_brief.group_definition.label, + role_brief.role_definition.role, + role_brief.markdown.trim() + ) +} + +pub(crate) fn render_agent_group_briefs_context(briefs: &[AgentGroupBrief]) -> String { + let mut output = String::new(); + for brief in briefs { + output.push_str(&format!( + "## {} / {}\n\n{}\n\n", + brief.definition.label, + brief.definition.role, + brief.markdown.trim() + )); + } + output +} + +pub(crate) fn render_agent_pass_handoff(pass: u8, draft: &LlmGameDraft) -> String { + let mut output = format!("# Agent Handoff\n\n- pass: {pass}\n\n"); + for handoff in &draft.handoffs { + output.push_str(&format!( + "## {} / {}\n\n{}\n\n- outputs: {}\n- next: {}\n\n", + handoff_group_label(&handoff.group), + handoff.role.trim(), + handoff.summary.trim(), + handoff + .outputs + .iter() + .map(|output| output.trim()) + .filter(|output| !output.is_empty()) + .collect::>() + .join(", "), + handoff.next.trim() + )); + } + output.push_str("## 总结\n\n"); + output.push_str(draft.handoff_summary.trim()); + output.push('\n'); + output +} + +pub(crate) fn append_collaboration_steps( + pass: u8, + draft: &LlmGameDraft, + paths: &AgentPassArtifactPaths, + steps: &mut Vec, +) { + let design = + handoff_summary_for_group(draft, "design", "拆出核心循环、胜负条件和第一版关卡目标"); + let balance = handoff_summary_for_group(draft, "balance", "沉淀速度、生命、得分和难度参数"); + let art = handoff_summary_for_group(draft, "art", "整理角色、场景、UI 和动画资产需求"); + let audio = handoff_summary_for_group(draft, "audio", "整理 BGM 和核心交互音效需求"); + let code = + handoff_summary_for_group(draft, "code", "生成可由本地 HTTP server 预览的 canvas 原型"); + let publishing = + handoff_summary_for_group(draft, "publishing", "整理标题、标签、说明和发布前检查"); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "策划组 / Gameplay", + "completed", + vec![".agent/spec.md".to_string(), paths.draft_json.clone()], + vec![ + paths.design_markdown.clone(), + paths.handoff_markdown.clone(), + ], + &design, + "agent.handoff.design", + ), + "design", + "Gameplay", + Some("design-foundation"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "数值组 / Difficulty", + "completed", + vec![paths.design_markdown.clone()], + vec![paths.balance_json.clone(), paths.handoff_markdown.clone()], + &balance, + "agent.handoff.balance", + ), + "balance", + "Difficulty", + Some("balance-seed"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "美术组 / Asset", + "completed", + vec![paths.design_markdown.clone()], + vec![ + paths.art_manifest_json.clone(), + paths.handoff_markdown.clone(), + ], + &art, + "agent.handoff.art", + ), + "art", + "Asset", + Some("art-asset-plan"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "音乐组 / SFX", + "completed", + vec![paths.design_markdown.clone()], + vec![ + paths.audio_manifest_json.clone(), + paths.handoff_markdown.clone(), + ], + &audio, + "agent.handoff.audio", + ), + "audio", + "SFX", + Some("audio-asset-plan"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "程序组 / Code", + "completed", + vec![ + paths.design_markdown.clone(), + paths.balance_json.clone(), + paths.art_manifest_json.clone(), + paths.audio_manifest_json.clone(), + ], + vec![paths.game_html.clone(), paths.handoff_markdown.clone()], + &code, + "agent.handoff.code", + ), + "code", + "Code", + Some("code-prototype"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "运营组 / Publish", + "completed", + vec![paths.design_markdown.clone(), paths.game_html.clone()], + vec![paths.publish_readme.clone(), paths.handoff_markdown.clone()], + &publishing, + "agent.handoff.publish", + ), + "publishing", + "Publish", + Some("publish-package"), + "handoff", + )); +} + +pub(crate) fn handoff_summary_for_group( + draft: &LlmGameDraft, + group: &str, + fallback: &str, +) -> String { + draft + .handoffs + .iter() + .find(|handoff| handoff.group.trim() == group) + .map(|handoff| handoff.summary.trim()) + .filter(|summary| !summary.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +pub(crate) fn handoff_group_label(group: &str) -> &'static str { + match group.trim() { + "design" => "策划组", + "balance" => "数值组", + "art" => "美术组", + "audio" => "音乐组", + "code" => "程序组", + "publishing" => "运营组", + _ => "专业组", + } +} + +pub(crate) fn append_agent_loop_log( + root: &Path, + loop_result: &GameCreatorAgentLoopResult, +) -> Result<(), String> { + let agent_log_path = root.join(".agent/logs/agent.log"); + let timestamp = unix_timestamp(); + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&agent_log_path) + .and_then(|mut file| { + file.write_all( + format!( + "{timestamp} agent.loop passes={}\nPlanner -> .agent/spec.md\n组内角色 briefs -> .agent/passes/pass-*/groups//*.md\n专业组汇总 -> .agent/passes/pass-*/groups/*.md\nGenerator -> .agent/passes/pass-*/draft.json\n专业组 handoffs -> .agent/passes/pass-*/handoff.md\nEvaluator -> .agent/findings.md\n{}\n{}\n", + loop_result.passes, + loop_result.spec_markdown.trim(), + loop_result.findings_markdown.trim() + ) + .as_bytes(), + ) + }) + .map_err(|error| format!("写入 Agent loop 日志失败:{}: {error}", agent_log_path.display()))?; + append_agent_loop_memory(root, loop_result) +} + +pub(crate) fn append_agent_loop_memory( + root: &Path, + loop_result: &GameCreatorAgentLoopResult, +) -> Result<(), String> { + let trace_path = root.join(".agent/run.latest.json"); + let trace_content = fs::read_to_string(&trace_path).map_err(|error| { + format!( + "读取 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + let trace = + serde_json::from_str::(&trace_content).map_err(|error| { + format!( + "解析 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + let timestamp = unix_timestamp(); + let final_artifacts = [ + "game/index.html", + "game/game_design.md", + "game/balance.json", + "assets/manifest.art.json", + "assets/manifest.audio.json", + "exports/README.md", + ] + .into_iter() + .filter(|path| { + trace + .artifacts + .iter() + .any(|artifact| artifact.path == *path) + }) + .collect::>() + .join(", "); + let active_tasks = join_or_none(&trace.task_graph.active_task_ids); + let carried_tasks = join_or_none(&trace.task_graph.carried_task_ids); + let short_entry = format!( + "\n## Agent Run {}\n\n- 时间:{}\n- 标题:{}\n- 状态:{};loop:{}/{};下一步:{}\n- activeTasks:{}\n- carryOverTasks:{}\n- 产物:{}\n", + loop_result.run_id, + timestamp, + loop_result.draft.title.trim(), + trace.status, + trace.passes, + trace.max_passes, + trace.next_step, + active_tasks, + carried_tasks, + if final_artifacts.is_empty() { + "none" + } else { + final_artifacts.as_str() + } + ); + append_markdown_entry( + &root.join("memory/session.md"), + "# 短期记忆\n\n", + &short_entry, + "写入短期 Agent 记忆失败", + )?; + + let long_entry = format!( + "\n## 最近稳定原型\n\n- 时间:{}\n- 标题:{}\n- runId:{}\n- 通过轮次:{}/{}\n- 可试玩入口:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n", + timestamp, + loop_result.draft.title.trim(), + loop_result.run_id, + trace.passes, + trace.max_passes + ); + append_markdown_entry( + &root.join("memory/project.md"), + "# 项目长期记忆\n\n## 当前约束\n\n- Web 小游戏原型\n- 本地 HTTP 预览\n\n", + &long_entry, + "写入长期 Agent 记忆失败", + ) +} + +pub(crate) fn join_or_none(values: &[String]) -> String { + if values.is_empty() { + "none".to_string() + } else { + values.join(", ") + } +} + +pub(crate) fn agent_trace_step( + pass: u8, + agent: &str, + status: &str, + input_paths: &[&str], + output_paths: &[&str], + summary: &str, + tool_id: &str, +) -> GameCreationAgentRunStep { + agent_trace_step_owned( + pass, + agent, + status, + input_paths + .iter() + .map(|path| (*path).to_string()) + .collect::>(), + output_paths + .iter() + .map(|path| (*path).to_string()) + .collect::>(), + summary, + tool_id, + ) +} + +pub(crate) fn agent_trace_step_owned( + pass: u8, + agent: &str, + status: &str, + input_paths: Vec, + output_paths: Vec, + summary: &str, + tool_id: &str, +) -> GameCreationAgentRunStep { + GameCreationAgentRunStep { + pass, + agent: agent.to_string(), + phase: infer_agent_trace_phase(tool_id).to_string(), + task_id: None, + group: None, + role: None, + status: status.to_string(), + input_paths: input_paths.clone(), + output_paths: output_paths.clone(), + summary: summary.to_string(), + tool_calls: vec![GameCreationAgentToolCallTrace { + tool_id: tool_id.to_string(), + status: status.to_string(), + input_paths, + output_paths, + summary: summary.to_string(), + }], + } +} + +pub(crate) fn infer_agent_trace_phase(tool_id: &str) -> &'static str { + if tool_id == "llm.chat.planner" { + "planning" + } else if tool_id.starts_with("agent.task_graph.") { + "orchestration" + } else if tool_id.starts_with("agent.role.brief.") { + "role-brief" + } else if tool_id.starts_with("agent.group.aggregate.") { + "group-aggregate" + } else if tool_id == "llm.chat.generator" { + "generation" + } else if tool_id.starts_with("agent.handoff.") { + "handoff" + } else if tool_id.starts_with("evaluator.") || tool_id == "file.write.findings" { + "evaluation" + } else if tool_id == "file.write.local_artifacts" { + "artifact-write" + } else if tool_id == "game.static_smoke" { + "playtest" + } else if tool_id.starts_with("preview.") { + "preview" + } else { + "tool" + } +} + +pub(crate) fn with_task_context( + mut step: GameCreationAgentRunStep, + group_id: &str, + role: &str, + task_id: Option<&str>, + phase: &str, +) -> GameCreationAgentRunStep { + step.phase = phase.to_string(); + step.group = game_creation_agent_group_from_id(group_id); + step.role = Some(role.to_string()); + step.task_id = task_id.map(str::to_string); + step +} + +pub(crate) fn game_creation_agent_group_from_id( + group_id: &str, +) -> Option { + match group_id { + "design" => Some(GameCreationAppAgentGroup::Design), + "balance" => Some(GameCreationAppAgentGroup::Balance), + "art" => Some(GameCreationAppAgentGroup::Art), + "audio" => Some(GameCreationAppAgentGroup::Audio), + "code" => Some(GameCreationAppAgentGroup::Code), + "publishing" => Some(GameCreationAppAgentGroup::Publishing), + _ => None, + } +} + +pub(crate) fn append_static_smoke_step( + root: &Path, + prompt: &str, + loop_result: &mut GameCreatorAgentLoopResult, +) -> Result<(), String> { + match run_limited_local_command_at(root, "game.static_smoke") { + Ok(smoke) => { + loop_result.steps.push(with_task_context( + agent_trace_step( + loop_result.passes, + "Playtest", + "completed", + &["game/index.html"], + &[".agent/logs/command.log", ".agent/manifest.json"], + &smoke.output, + "game.static_smoke", + ), + "code", + "Preview", + Some("preview-readiness"), + "playtest", + )); + write_agent_run_trace( + root, + &loop_result.run_id, + prompt, + "passed", + loop_result.passes, + &loop_result.steps, + None, + ) + } + Err(error) => { + loop_result.steps.push(with_task_context( + agent_trace_step( + loop_result.passes, + "Playtest", + "failed", + &["game/index.html"], + &[".agent/logs/command.log"], + &error, + "game.static_smoke", + ), + "code", + "Preview", + Some("preview-readiness"), + "playtest", + )); + write_agent_run_trace( + root, + &loop_result.run_id, + prompt, + "failed", + loop_result.passes, + &loop_result.steps, + Some(&error), + )?; + Err(format!("生成后自检失败:{error}")) + } + } +} + +pub(crate) fn append_static_smoke_manual_trace_step( + root: &Path, + result: &LimitedLocalCommandResult, +) -> Result<(), String> { + append_agent_run_trace_step( + root, + "passed", + "preview-playtest", + with_task_context( + agent_trace_step( + 0, + "Playtest", + "completed", + &["game/index.html"], + &[".agent/logs/command.log", ".agent/manifest.json"], + &result.output, + "game.static_smoke", + ), + "code", + "Preview", + Some("preview-readiness"), + "playtest", + ), + None, + ) +} + +pub(crate) fn append_local_artifact_write_step( + root: &Path, + prompt: &str, + loop_result: &mut GameCreatorAgentLoopResult, +) -> Result<(), String> { + loop_result.steps.push(agent_trace_step_owned( + loop_result.passes, + "ArtifactWriter", + "completed", + vec![ + format!(".agent/passes/pass-{}/draft.json", loop_result.passes), + format!(".agent/passes/pass-{}/handoff.md", loop_result.passes), + ], + vec![ + "memory/session.md".to_string(), + "memory/project.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + "memory/agents/".to_string(), + "game/game_design.md".to_string(), + "game/balance.json".to_string(), + "assets/manifest.art.json".to_string(), + "assets/manifest.audio.json".to_string(), + "exports/README.md".to_string(), + "game/index.html".to_string(), + ".agent/manifest.json".to_string(), + ], + "把通过 Evaluator 的草案写入本地项目产物", + "file.write.local_artifacts", + )); + write_agent_run_trace( + root, + &loop_result.run_id, + prompt, + "artifacts-written", + loop_result.passes, + &loop_result.steps, + None, + ) +} + +pub(crate) fn append_preview_start_trace_step( + root: &Path, + preview: &LocalPreviewResult, +) -> Result<(), String> { + append_agent_run_trace_step( + root, + "preview-running", + "manual-playtest", + with_task_context( + agent_trace_step( + 0, + "Preview", + "running", + &["game/index.html"], + &[".agent/manifest.json", ".agent/logs/preview.log"], + &format!("本地 HTTP 预览已启动:{}", preview.url), + "preview.start", + ), + "code", + "Playtest", + Some("preview-playtest"), + "preview", + ), + None, + ) +} + +pub(crate) fn append_preview_stop_trace_step(root: &Path) -> Result<(), String> { + append_agent_run_trace_step( + root, + "preview-stopped", + "inspect-artifacts", + with_task_context( + agent_trace_step( + 0, + "Preview", + "stopped", + &[".agent/manifest.json"], + &[".agent/manifest.json", ".agent/logs/preview.log"], + "本地 HTTP 预览已停止", + "preview.stop", + ), + "code", + "Playtest", + Some("preview-playtest"), + "preview", + ), + None, + ) +} + +pub(crate) fn append_preview_log( + root: &Path, + status: &str, + url: Option<&str>, +) -> Result<(), String> { + let log_path = root.join(".agent/logs/preview.log"); + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建预览日志目录失败:{}: {error}", parent.display()))?; + } + let line = match url { + Some(url) => format!("{} preview.{status} {url}\n", unix_timestamp()), + None => format!("{} preview.{status}\n", unix_timestamp()), + }; + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .and_then(|mut file| file.write_all(line.as_bytes())) + .map_err(|error| format!("写入预览日志失败:{}: {error}", log_path.display())) +} + +pub(crate) fn record_replaced_preview_stop(preview: &LocalPreviewResult) { + let root = Path::new(&preview.root); + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None); + let _ = append_preview_log(root, "stopped", None); + let _ = append_preview_stop_trace_step(root); +} + +pub(crate) fn append_agent_run_trace_step( + root: &Path, + status: &str, + next_step: &str, + step: GameCreationAgentRunStep, + error: Option<&str>, +) -> Result<(), String> { + let trace_path = root.join(".agent/run.latest.json"); + let content = match fs::read_to_string(&trace_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Agent run trace 失败:{}: {error}", + trace_path.display() + )) + } + }; + let mut trace = + serde_json::from_str::(&content).map_err(|error| { + format!( + "解析 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + trace.steps.push(step); + trace.status = status.to_string(); + trace.next_step = next_step.to_string(); + trace.error = error.map(str::to_string); + trace.stop_reason = agent_run_stop_reason(status, error).to_string(); + trace.tool_call_count = count_agent_tool_calls(&trace.steps)?; + trace.max_tool_calls = GAME_CREATOR_AGENT_TOOL_CALL_MAX; + trace.artifacts = collect_agent_run_artifacts(root)?; + trace.task_graph = + build_agent_run_task_graph_trace(root, &trace.goal, trace.passes, &trace.steps)?; + trace.pass_plans = collect_agent_pass_plan_traces(root, trace.passes)?; + trace.updated_at = unix_timestamp(); + write_agent_run_trace_payload(root, &trace) +} + +#[derive(Default)] +pub(crate) struct AgentAgendaSnapshot { + active_task_ids: Vec, + carried_task_ids: Vec, + repair_focus: Vec, + repair_routes: Vec, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentPassTaskGraphSnapshot { + #[serde(default)] + pass: u8, + #[serde(default)] + mode: String, + #[serde(default)] + summary: String, + #[serde(default)] + active_task_ids: Vec, + #[serde(default)] + carried_task_ids: Vec, + #[serde(default)] + dependency_waves: Vec>, + #[serde(default)] + repair_focus: Vec, + #[serde(default)] + repair_routes: Vec, +} + +pub(crate) fn build_agent_run_task_graph_trace( + root: &Path, + goal: &str, + pass: u8, + steps: &[GameCreationAgentRunStep], +) -> Result { + let agenda = read_agent_agenda_snapshot(root, pass)?; + let mut tasks = new_game_creation_app_seed_tasks(); + + for task_id in &agenda.active_task_ids { + set_task_status_if_current(&mut tasks, task_id, GameCreationAppTaskStatus::Running); + } + for task_id in &agenda.carried_task_ids { + set_task_status_if_current(&mut tasks, task_id, GameCreationAppTaskStatus::Completed); + } + + for step in steps { + let Some(task_id) = step.task_id.as_deref() else { + continue; + }; + let Some(status) = task_status_from_agent_step(step, task_id) else { + continue; + }; + set_task_status_if_current(&mut tasks, task_id, status); + } + + if task_has_status( + &tasks, + "preview-readiness", + GameCreationAppTaskStatus::Completed, + ) && !steps.iter().any(|step| { + step.task_id.as_deref() == Some("preview-playtest") + && step.phase == "preview" + && step.status == "running" + }) { + set_task_status_if_current( + &mut tasks, + "preview-playtest", + GameCreationAppTaskStatus::WaitingForConfirmation, + ); + } + + Ok(GameCreationAgentRunTaskGraphTrace { + goal: goal.trim().to_string(), + ready_task_ids: ready_task_ids_for_tasks(&tasks), + active_task_ids: agenda.active_task_ids, + carried_task_ids: agenda.carried_task_ids, + repair_focus: agenda.repair_focus, + repair_routes: agenda.repair_routes, + tasks, + }) +} + +pub(crate) fn read_agent_agenda_snapshot( + root: &Path, + pass: u8, +) -> Result { + if pass == 0 { + return Ok(AgentAgendaSnapshot::default()); + } + let task_graph_relative_path = format!(".agent/passes/pass-{pass}/task-graph.json"); + match fs::read_to_string(root.join(&task_graph_relative_path)) { + Ok(content) => { + let snapshot: AgentPassTaskGraphSnapshot = + serde_json::from_str(&content).map_err(|error| { + format!("解析 Agent task graph 失败:{task_graph_relative_path}: {error}") + })?; + return Ok(AgentAgendaSnapshot { + active_task_ids: snapshot.active_task_ids, + carried_task_ids: snapshot.carried_task_ids, + repair_focus: snapshot.repair_focus, + repair_routes: snapshot.repair_routes, + }); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Agent task graph 失败:{task_graph_relative_path}: {error}" + )) + } + } + let relative_path = format!(".agent/passes/pass-{pass}/agenda.md"); + let agenda = match fs::read_to_string(root.join(&relative_path)) { + Ok(agenda) => agenda, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(AgentAgendaSnapshot::default()) + } + Err(error) => return Err(format!("读取 Agent agenda 失败:{relative_path}: {error}")), + }; + let mut snapshot = AgentAgendaSnapshot::default(); + let mut in_repair_focus = false; + for line in agenda.lines().map(str::trim) { + if let Some(value) = line.strip_prefix("- activeTasks:") { + snapshot.active_task_ids = parse_agent_task_id_list(value); + continue; + } + if let Some(value) = line.strip_prefix("- carriedTasks:") { + snapshot.carried_task_ids = parse_agent_task_id_list(value); + continue; + } + if line == "## Repair Focus" { + in_repair_focus = true; + continue; + } + if line.starts_with("## ") { + in_repair_focus = false; + } + if in_repair_focus { + if let Some(issue) = line.strip_prefix("- ") { + let issue = issue.trim(); + if !issue.is_empty() && !issue.contains("首轮生成") { + snapshot.repair_focus.push(issue.to_string()); + } + } + } + } + Ok(snapshot) +} + +pub(crate) fn collect_agent_pass_plan_traces( + root: &Path, + passes: u8, +) -> Result, String> { + let mut plans = Vec::new(); + for pass in 1..=passes { + let relative_path = format!(".agent/passes/pass-{pass}/task-graph.json"); + let content = match fs::read_to_string(root.join(&relative_path)) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!( + "读取 Agent pass plan 失败:{relative_path}: {error}" + )) + } + }; + let snapshot: AgentPassTaskGraphSnapshot = serde_json::from_str(&content) + .map_err(|error| format!("解析 Agent pass plan 失败:{relative_path}: {error}"))?; + plans.push(GameCreationAgentPassPlanTrace { + pass: if snapshot.pass == 0 { + pass + } else { + snapshot.pass + }, + mode: snapshot.mode, + summary: snapshot.summary, + active_task_ids: snapshot.active_task_ids, + carried_task_ids: snapshot.carried_task_ids, + dependency_waves: snapshot.dependency_waves, + repair_focus: snapshot.repair_focus, + repair_routes: snapshot.repair_routes, + }); + } + Ok(plans) +} + +pub(crate) fn parse_agent_task_id_list(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|task_id| !task_id.is_empty() && *task_id != "none") + .map(str::to_string) + .collect() +} + +pub(crate) fn task_status_from_agent_step( + step: &GameCreationAgentRunStep, + task_id: &str, +) -> Option { + if step.status == "failed" { + return Some(GameCreationAppTaskStatus::Failed); + } + if step.status == "running" { + return Some(GameCreationAppTaskStatus::Running); + } + if step.status == "stopped" && step.phase == "preview" { + return Some(GameCreationAppTaskStatus::WaitingForConfirmation); + } + if step.status == "carried-over" { + return Some(GameCreationAppTaskStatus::Completed); + } + if step.status != "completed" && step.status != "passed" { + return None; + } + + match step.phase.as_str() { + "planning" | "handoff" | "playtest" | "evaluation" => { + Some(GameCreationAppTaskStatus::Completed) + } + "preview" => Some(GameCreationAppTaskStatus::Running), + "role-brief" if role_brief_completes_task(task_id) => { + Some(GameCreationAppTaskStatus::Completed) + } + "role-brief" => Some(GameCreationAppTaskStatus::Running), + _ => None, + } +} + +pub(crate) fn role_brief_completes_task(task_id: &str) -> bool { + matches!( + task_id, + "balance-director" + | "art-director" + | "art-polish" + | "audio-director" + | "code-director" + | "publish-strategy" + ) +} + +pub(crate) fn set_task_status_if_current( + tasks: &mut [GameCreationAppTaskState], + task_id: &str, + status: GameCreationAppTaskStatus, +) { + if let Some(task) = tasks.iter_mut().find(|task| task.id == task_id) { + if should_replace_task_status(&task.status, &status) { + task.status = status; + } + } +} + +pub(crate) fn should_replace_task_status( + current: &GameCreationAppTaskStatus, + next: &GameCreationAppTaskStatus, +) -> bool { + use GameCreationAppTaskStatus as Status; + status_rank(next) >= status_rank(current) + || matches!( + (current, next), + (Status::Running, Status::Completed) + | (Status::WaitingForConfirmation, Status::Running) + | (Status::Pending, _) + ) +} + +pub(crate) fn status_rank(status: &GameCreationAppTaskStatus) -> u8 { + match status { + GameCreationAppTaskStatus::Pending => 0, + GameCreationAppTaskStatus::Running => 1, + GameCreationAppTaskStatus::WaitingForConfirmation => 2, + GameCreationAppTaskStatus::Completed => 3, + GameCreationAppTaskStatus::Failed => 4, + } +} + +pub(crate) fn task_has_status( + tasks: &[GameCreationAppTaskState], + task_id: &str, + status: GameCreationAppTaskStatus, +) -> bool { + tasks + .iter() + .find(|task| task.id == task_id) + .is_some_and(|task| task.status == status) +} + +pub(crate) fn ready_task_ids_for_tasks(tasks: &[GameCreationAppTaskState]) -> Vec { + tasks + .iter() + .filter(|task| { + task.status == GameCreationAppTaskStatus::Pending + && task.dependencies.iter().all(|dependency| { + task_has_status(tasks, dependency, GameCreationAppTaskStatus::Completed) + }) + }) + .map(|task| task.id.clone()) + .collect() +} + +pub(crate) fn write_agent_run_trace( + root: &Path, + run_id: &str, + prompt: &str, + status: &str, + passes: u8, + steps: &[GameCreationAgentRunStep], + error: Option<&str>, +) -> Result<(), String> { + let tool_call_count = count_agent_tool_calls(steps)?; + let trace = GameCreationAgentRunTrace { + schema_version: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION.to_string(), + run_id: run_id.to_string(), + command_id: "game.generate_draft".to_string(), + status: status.to_string(), + lifecycle_status: Some(agent_run_lifecycle_status(status).to_string()), + passes, + max_passes: GAME_CREATOR_AGENT_LOOP_MAX_PASSES, + tool_call_count, + max_tool_calls: GAME_CREATOR_AGENT_TOOL_CALL_MAX, + stop_reason: agent_run_stop_reason(status, error).to_string(), + goal: prompt.trim().to_string(), + coordination: "filesystem".to_string(), + steps: steps.to_vec(), + artifacts: collect_agent_run_artifacts(root)?, + task_graph: build_agent_run_task_graph_trace(root, prompt, passes, steps)?, + pass_plans: collect_agent_pass_plan_traces(root, passes)?, + next_step: match status { + "passed" => "preview-playtest", + "artifacts-written" => "game.static_smoke", + "failed" => "inspect-error", + _ => "generator-revision", + } + .to_string(), + error: error.map(str::to_string), + updated_at: unix_timestamp(), + }; + write_agent_run_trace_payload(root, &trace) +} + +pub(crate) fn agent_run_stop_reason(status: &str, error: Option<&str>) -> &'static str { + match status { + "running" => "loop-running", + "needs-revision" => "evaluator-needs-revision", + "passed" => "evaluator-passed", + "artifacts-written" => "artifacts-written", + "preview-running" => "preview-running", + "preview-stopped" => "preview-stopped", + "failed" if error.is_some_and(|message| message.contains("已重试")) => { + "max-passes-exhausted" + } + "failed" => "failed", + _ => "unknown", + } +} + +pub(crate) fn agent_run_lifecycle_status(status: &str) -> &'static str { + match status { + "running" | "needs-revision" | "artifacts-written" | "preview-running" => "running", + "waiting" | "preview-stopped" => "waiting", + "pending" => "pending", + "killed" => "killed", + "failed" => "failed", + "passed" => "done", + _ => "scheduled", + } +} + +pub(crate) fn append_agent_run_activity( + root: &Path, + run_id: &str, + event: &str, + message: &str, +) -> Result<(), String> { + append_agent_run_jsonl( + root, + ".agent/activity.jsonl", + &serde_json::json!({ + "timestamp": unix_timestamp(), + "runId": run_id, + "event": event, + "message": message, + }), + ) +} + +pub(crate) fn append_agent_run_output( + root: &Path, + run_id: &str, + event: &str, + message: &str, +) -> Result<(), String> { + append_agent_run_jsonl( + root, + ".agent/output.jsonl", + &serde_json::json!({ + "timestamp": unix_timestamp(), + "runId": run_id, + "event": event, + "content": message, + }), + ) +} + +pub(crate) fn append_agent_run_jsonl( + root: &Path, + relative_path: &str, + value: &serde_json::Value, +) -> Result<(), String> { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 Agent 事件目录失败:{}: {error}", parent.display()))?; + } + let mut line = + serde_json::to_string(value).map_err(|error| format!("序列化 Agent 事件失败:{error}"))?; + line.push('\n'); + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut file| file.write_all(line.as_bytes())) + .map_err(|error| format!("写入 Agent 事件失败:{}: {error}", path.display())) +} + +pub(crate) fn write_agent_run_context_bundle( + root: &Path, + trace: &GameCreationAgentRunTrace, +) -> Result<(), String> { + let bundle_path = root.join(".agent/context.bundle.json"); + if let Some(parent) = bundle_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent context bundle 目录失败:{}: {error}", + parent.display() + ) + })?; + } + let manifest = read_manifest_for_project(root).ok(); + let payload = serde_json::json!({ + "schemaVersion": "game-creator-context-bundle.v1", + "runId": trace.run_id, + "commandId": trace.command_id, + "goal": trace.goal, + "lifecycleStatus": trace.lifecycle_status.as_deref().unwrap_or_else(|| agent_run_lifecycle_status(&trace.status)), + "status": trace.status, + "nextStep": trace.next_step, + "memory": { + "short": "memory/session.md", + "long": "memory/project.md", + "blackboard": PROJECT_BLACKBOARD_MEMORY_PATH, + "agents": "memory/agents/" + }, + "manifest": manifest, + "trace": ".agent/run.latest.json", + "activity": ".agent/activity.jsonl", + "output": ".agent/output.jsonl", + "updatedAt": unix_timestamp() + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成 Agent context bundle 失败:{error}"))?; + fs::write(&bundle_path, content).map_err(|error| { + format!( + "写入 Agent context bundle 失败:{}: {error}", + bundle_path.display() + ) + }) +} + +pub(crate) fn update_agent_run_lifecycle( + root: &Path, + action: &str, + detail: Option<&str>, +) -> Result { + let mut trace = read_latest_agent_run_trace(root)?; + let (status, lifecycle_status, next_step, event, message) = match action { + "status" => { + let lifecycle = trace + .lifecycle_status + .clone() + .unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string()); + ( + trace.status.clone(), + lifecycle.clone(), + trace.next_step.clone(), + "agent.run_status", + format!( + "run {} 当前状态:{} / {}", + trace.run_id, trace.status, lifecycle + ), + ) + } + "kill" => ( + "killed".to_string(), + "killed".to_string(), + "resume-or-retry".to_string(), + "agent.kill", + format!("run {} 已标记为 killed", trace.run_id), + ), + "retry" => ( + "pending".to_string(), + "pending".to_string(), + "rerun-now".to_string(), + "agent.retry", + format!("run {} 已请求重试", trace.run_id), + ), + "resume" => ( + "pending".to_string(), + "pending".to_string(), + "rerun-now".to_string(), + "agent.resume", + format!( + "run {} 已恢复:{}", + trace.run_id, + detail.unwrap_or("继续运行最近目标") + ), + ), + _ => return Err("未知 Agent run 控制动作".to_string()), + }; + + if action != "status" { + trace.status = status; + trace.lifecycle_status = Some(lifecycle_status); + trace.next_step = next_step; + trace.stop_reason = match action { + "kill" => "killed", + "retry" => "retry-requested", + "resume" => "human-resume", + _ => trace.stop_reason.as_str(), + } + .to_string(); + trace.error = if action == "kill" { + Some("用户请求停止当前 run".to_string()) + } else { + None + }; + trace.updated_at = unix_timestamp(); + write_agent_run_trace_payload(root, &trace)?; + } + + append_agent_run_activity(root, &trace.run_id, event, &message)?; + append_agent_run_output(root, &trace.run_id, event, &message)?; + write_agent_run_context_bundle(root, &trace)?; + + agent_run_control_result_from_trace(root, trace, message) +} + +pub(crate) async fn control_agent_run_at( + root: &Path, + action: &str, + detail: Option<&str>, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Result { + let previous_trace = read_latest_agent_run_trace(root)?; + let control_result = update_agent_run_lifecycle(root, action, detail)?; + if !matches!(action, "retry" | "resume") { + return Ok(control_result); + } + + let prompt = resumed_agent_run_prompt(&previous_trace.goal, action, detail); + let generated = generate_local_game_draft_at(root, &prompt, progress).await?; + let trace = read_latest_agent_run_trace(root)?; + let message = format!( + "{},已重新运行为 {}:{}", + control_result.message, trace.run_id, generated.game_index_path + ); + let event = if action == "retry" { + "agent.retry.run" + } else { + "agent.resume.run" + }; + append_agent_run_activity(root, &trace.run_id, event, &message)?; + append_agent_run_output(root, &trace.run_id, event, &message)?; + write_agent_run_context_bundle(root, &trace)?; + agent_run_control_result_from_trace(root, trace, message) +} + +pub(crate) fn resumed_agent_run_prompt(goal: &str, action: &str, detail: Option<&str>) -> String { + let goal = goal.trim(); + let detail = detail.map(str::trim).filter(|value| !value.is_empty()); + match (action, detail) { + ("resume", Some(detail)) => format!("{goal}\n\n继续说明:{detail}"), + _ => goal.to_string(), + } +} + +pub(crate) fn read_latest_agent_run_trace( + root: &Path, +) -> Result { + let trace_path = root.join(".agent/run.latest.json"); + let content = fs::read_to_string(&trace_path).map_err(|error| { + format!( + "读取 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + serde_json::from_str::(&content).map_err(|error| { + format!( + "解析 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + }) +} + +pub(crate) fn agent_run_control_result_from_trace( + root: &Path, + trace: GameCreationAgentRunTrace, + message: String, +) -> Result { + Ok(AgentRunControlResult { + run_id: trace.run_id, + lifecycle_status: trace + .lifecycle_status + .clone() + .unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string()), + status: trace.status, + next_step: trace.next_step, + message, + activity_path: root + .join(".agent/activity.jsonl") + .to_string_lossy() + .to_string(), + output_path: root + .join(".agent/output.jsonl") + .to_string_lossy() + .to_string(), + context_bundle_path: root + .join(".agent/context.bundle.json") + .to_string_lossy() + .to_string(), + }) +} + +pub(crate) fn count_agent_tool_calls(steps: &[GameCreationAgentRunStep]) -> Result { + let count = steps.iter().try_fold(0u16, |current, step| { + let step_count = u16::try_from(step.tool_calls.len()) + .map_err(|_| "Agent 工具调用数超过上限".to_string())?; + current + .checked_add(step_count) + .ok_or_else(|| "Agent 工具调用数超过上限".to_string()) + })?; + if count > GAME_CREATOR_AGENT_TOOL_CALL_MAX { + return Err(format!( + "Agent 工具调用预算超限:{count}/{GAME_CREATOR_AGENT_TOOL_CALL_MAX}" + )); + } + Ok(count) +} + +pub(crate) fn write_agent_run_trace_payload( + root: &Path, + trace: &GameCreationAgentRunTrace, +) -> Result<(), String> { + if trace.run_id.contains('/') || trace.run_id.contains('\\') || trace.run_id.contains("..") { + return Err("Agent run_id 非法".to_string()); + } + let payload = serde_json::to_string_pretty(&trace) + .map_err(|error| format!("生成 Agent run trace 失败:{error}"))?; + let latest_path = root.join(".agent/run.latest.json"); + fs::write(&latest_path, &payload).map_err(|error| { + format!( + "写入 Agent run trace 失败:{}: {error}", + latest_path.display() + ) + })?; + let run_dir = root.join(".agent/runs"); + fs::create_dir_all(&run_dir).map_err(|error| { + format!( + "创建 Agent run history 目录失败:{}: {error}", + run_dir.display() + ) + })?; + let run_path = run_dir.join(format!("{}.json", trace.run_id)); + fs::write(&run_path, payload).map_err(|error| { + format!( + "写入 Agent run history 失败:{}: {error}", + run_path.display() + ) + })?; + prune_agent_run_history(&run_dir) +} + +pub(crate) fn prune_agent_run_history(run_dir: &Path) -> Result<(), String> { + let entries = match fs::read_dir(run_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Agent run history 失败:{}: {error}", + run_dir.display() + )); + } + }; + let mut run_files = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "读取 Agent run history 失败:{}: {error}", + run_dir.display() + ) + })?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let updated_at = fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .map(|trace| trace.updated_at) + .unwrap_or(0); + run_files.push((updated_at, path)); + } + if run_files.len() <= GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT { + return Ok(()); + } + run_files.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); + for (_, path) in run_files + .into_iter() + .skip(GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT) + { + fs::remove_file(&path).map_err(|error| { + format!("删除旧 Agent run history 失败:{}: {error}", path.display()) + })?; + } + Ok(()) +} + +pub(crate) fn collect_agent_run_artifacts( + root: &Path, +) -> Result, String> { + let mut relative_paths = GAME_CREATOR_AGENT_ARTIFACT_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect::>(); + let passes_dir = root.join(".agent/passes"); + if passes_dir.exists() { + let mut pass_dirs = fs::read_dir(&passes_dir) + .map_err(|error| { + format!( + "读取 Agent pass 目录失败:{}: {error}", + passes_dir.display() + ) + })? + .collect::, _>>() + .map_err(|error| { + format!( + "读取 Agent pass 目录失败:{}: {error}", + passes_dir.display() + ) + })?; + pass_dirs.sort_by_key(|entry| entry.path()); + for pass_dir in pass_dirs { + let pass_path = pass_dir.path(); + let metadata = fs::symlink_metadata(&pass_path).map_err(|error| { + format!( + "读取 Agent pass 元数据失败:{}: {error}", + pass_path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err("Agent pass 目录不能是符号链接".to_string()); + } + if !metadata.is_dir() { + continue; + } + let mut dirs = vec![pass_path]; + while let Some(dir) = dirs.pop() { + let mut entries = fs::read_dir(&dir) + .map_err(|error| { + format!("读取 Agent pass 文件失败:{}: {error}", dir.display()) + })? + .collect::, _>>() + .map_err(|error| { + format!("读取 Agent pass 文件失败:{}: {error}", dir.display()) + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let entry_path = entry.path(); + let metadata = fs::symlink_metadata(&entry_path).map_err(|error| { + format!( + "读取 Agent pass 文件元数据失败:{}: {error}", + entry_path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err("Agent pass artifact 不能是符号链接".to_string()); + } + if metadata.is_dir() { + dirs.push(entry_path); + } else if metadata.is_file() { + relative_paths.push(relative_project_path(root, &entry_path)?); + } + } + } + } + } + let agent_memory_dir = root.join("memory/agents"); + if agent_memory_dir.exists() { + let mut dirs = vec![agent_memory_dir]; + while let Some(dir) = dirs.pop() { + let mut entries = fs::read_dir(&dir) + .map_err(|error| { + format!("读取 Agent 私有记忆目录失败:{}: {error}", dir.display()) + })? + .collect::, _>>() + .map_err(|error| { + format!("读取 Agent 私有记忆目录失败:{}: {error}", dir.display()) + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let entry_path = entry.path(); + let metadata = fs::symlink_metadata(&entry_path).map_err(|error| { + format!( + "读取 Agent 私有记忆元数据失败:{}: {error}", + entry_path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err("Agent 私有记忆不能是符号链接".to_string()); + } + if metadata.is_dir() { + dirs.push(entry_path); + } else if metadata.is_file() { + relative_paths.push(relative_project_path(root, &entry_path)?); + } + } + } + } + + relative_paths.sort(); + relative_paths.dedup(); + let mut artifacts = Vec::new(); + for relative_path in relative_paths { + let path = root.join(&relative_path); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!( + "读取 Agent artifact 元数据失败:{}: {error}", + path.display() + )) + } + }; + if metadata.file_type().is_symlink() { + return Err("Agent artifact 不能是符号链接".to_string()); + } + if !metadata.is_file() { + continue; + } + let bytes = fs::read(&path) + .map_err(|error| format!("读取 Agent artifact 失败:{}: {error}", path.display()))?; + artifacts.push(GameCreationAgentArtifactTrace { + path: relative_path, + size_bytes: metadata.len(), + checksum: format!("fnv1a64:{:016x}", fnv1a64(&bytes)), + }); + } + Ok(artifacts) +} + +pub(crate) fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +pub(crate) fn parse_llm_game_draft_response(content: &str) -> Result { + let content = strip_llm_thinking_blocks(content); + let payload = extract_json_payload(content.as_str()) + .ok_or_else(|| "LLM 返回不是 JSON 对象".to_string())?; + serde_json::from_str::(payload) + .map_err(|error| format!("解析 LLM 游戏草案失败:{error}")) +} + +pub(crate) fn strip_llm_thinking_blocks(content: &str) -> String { + let mut output = String::new(); + let mut rest = content; + loop { + let Some(start) = rest.to_ascii_lowercase().find("") else { + output.push_str(rest); + break; + }; + output.push_str(&rest[..start]); + let after_start = &rest[start + "".len()..]; + let Some(end) = after_start.to_ascii_lowercase().find("") else { + break; + }; + rest = &after_start[end + "".len()..]; + } + output.trim().to_string() +} + +pub(crate) fn extract_json_payload(content: &str) -> Option<&str> { + let trimmed = content.trim(); + let without_fence = trimmed + .strip_prefix("```json") + .or_else(|| trimmed.strip_prefix("```")) + .and_then(|value| value.strip_suffix("```")) + .map(str::trim) + .unwrap_or(trimmed); + let start = without_fence.find('{')?; + let end = without_fence.rfind('}')?; + if start > end { + return None; + } + Some(&without_fence[start..=end]) +} + +pub(crate) fn validate_llm_game_draft(prompt: &str, draft: &LlmGameDraft) -> Result<(), String> { + if draft.title.trim().is_empty() { + return Err("LLM 草案缺少标题".to_string()); + } + if draft.design_markdown.trim().is_empty() { + return Err("LLM 草案缺少设计说明".to_string()); + } + if !draft.balance.is_object() { + return Err("LLM 草案 balance 必须是 JSON object".to_string()); + } + if !draft.art_manifest.is_object() { + return Err("LLM 草案 artManifest 必须是 JSON object".to_string()); + } + if !draft.audio_manifest.is_object() { + return Err("LLM 草案 audioManifest 必须是 JSON object".to_string()); + } + if draft.publish_readme.trim().is_empty() { + return Err("LLM 草案缺少发布说明".to_string()); + } + if draft.handoff_summary.trim().is_empty() { + return Err("LLM 草案缺少多智能体交接摘要".to_string()); + } + validate_llm_agent_handoffs(draft)?; + + let html = draft.game_html.trim(); + let lower_html = html.to_ascii_lowercase(); + if !lower_html.contains("')) && html.contains(prompt) { + return Err("LLM 草案 gameHtml 包含未转义的用户输入".to_string()); + } + validate_playable_game_html(html, "LLM 草案 gameHtml")?; + validate_non_placeholder_game_html(html, "LLM 草案 gameHtml")?; + + Ok(()) +} + +pub(crate) fn validate_playable_game_html(html: &str, label: &str) -> Result<(), String> { + let lower_html = html.to_ascii_lowercase(); + if !contains_any( + &lower_html, + &[ + "目标", + "任务", + "goal", + "objective", + "点亮", + "收集", + "抵达", + "获胜", + "通关", + "连击", + "combo", + "survive", + ], + ) { + return Err(format!("{label} 必须展示明确目标")); + } + if !contains_any( + &lower_html, + &[ + "胜利", + "获胜", + "失败", + "game over", + "win", + "lose", + "victory", + "defeat", + ], + ) { + return Err(format!("{label} 必须包含失败或胜利状态")); + } + if !contains_any( + &lower_html, + &["重开", "重新开始", "restart", "reset", "again", "再来"], + ) { + return Err(format!("{label} 必须包含重开路径")); + } + Ok(()) +} + +pub(crate) fn validate_safe_game_html_runtime(html: &str, label: &str) -> Result<(), String> { + let lower_html = html.to_ascii_lowercase(); + if lower_html.contains(" - -"# - .to_string(), - } - } - - fn fake_agent_handoffs() -> Vec { - vec![ - handoff( - "design", - "Gameplay", - "定义反弹循环", - ["game/game_design.md"], - "交给数值和程序组", - ), - handoff( - "balance", - "Difficulty", - "设置生命和速度", - ["game/balance.json"], - "交给程序组读取", - ), - handoff( - "art", - "Asset", - "规划厨房角色和场景资产", - ["assets/manifest.art.json"], - "进入画板链路", - ), - handoff( - "audio", - "SFX", - "规划反弹音效和 BGM", - ["assets/manifest.audio.json"], - "进入音频生成链路", - ), - handoff( - "code", - "Code", - "生成 canvas 原型", - ["game/index.html"], - "交给 Playtest", - ), - handoff( - "publishing", - "Publish", - "整理标题和标签", - ["exports/README.md"], - "等待预览验收", - ), - ] - } - - fn handoff( - group: &str, - role: &str, - summary: &str, - outputs: [&str; N], - next: &str, - ) -> LlmAgentHandoff { - LlmAgentHandoff { - group: group.to_string(), - role: role.to_string(), - summary: summary.to_string(), - outputs: outputs.map(str::to_string).to_vec(), - next: next.to_string(), - } - } - - fn write_test_canvas_export_zip(path: &Path) { - let file = File::create(path).expect("create canvas export zip"); - let mut writer = zip::ZipWriter::new(file); - let options = SimpleFileOptions::default(); - writer - .start_file("月光画布-画布素材/images/001-月光主角.png", options) - .expect("start image file"); - writer.write_all(b"fake-png").expect("write image"); - writer - .start_file("月光画布-画布素材/media/002-玻璃月光.mp3", options) - .expect("start audio file"); - writer.write_all(b"fake-mp3").expect("write audio"); - writer - .start_file("月光画布-画布素材/manifest.txt", options) - .expect("start manifest"); - writer - .write_all("项目:月光画布\n素材数量:2\n".as_bytes()) - .expect("write manifest"); - writer - .start_file("月光画布-画布素材/metadata.json", options) - .expect("start metadata"); - writer - .write_all( - serde_json::json!({ - "projectTitle": "月光画布", - "exportedAt": "2026-06-24T00:00:00.000Z", - "layers": [ - { - "title": "月光主角", - "file": "images/001-月光主角.png", - "visible": { - "type": "角色", - "generationInputs": null, - "model": "gpt-image-2", - "task": "42", - "object": "asset-object-1", - "resolution": "512 x 512 px" - } - }, - { - "title": "玻璃月光 BGM", - "file": "media/002-玻璃月光.mp3", - "visible": { - "type": "音乐", - "generationInputs": null, - "model": "-", - "task": "-", - "object": "-", - "duration": "12s" - } - } - ], - "failedImages": [] - }) - .to_string() - .as_bytes(), - ) - .expect("write metadata"); - writer.finish().expect("finish canvas export zip"); - } - - fn spawn_mock_llm_server(response_content: String) -> String { - spawn_mock_llm_server_responses(vec![response_content]) - } - - fn spawn_mock_llm_server_responses(response_contents: Vec) -> String { - spawn_mock_llm_server_responses_with_capture(response_contents, None) - } - - fn spawn_mock_llm_server_responses_with_capture( - response_contents: Vec, - request_sender: Option>, - ) -> String { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock llm bind"); - let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); - std::thread::spawn(move || { - for response_content in response_contents { - let (mut stream, _) = listener.accept().expect("mock llm accept"); - let mut request_buffer = [0_u8; 8192]; - let read_len = stream.read(&mut request_buffer).unwrap_or(0); - if let Some(sender) = request_sender.as_ref() { - let _ = sender - .send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned()); - } - let request_text = String::from_utf8_lossy(&request_buffer[..read_len]); - let body = if request_text.contains("POST /responses HTTP/1.1") { - serde_json::json!({ - "id": "resp_game_creator_mock", - "model": "mock-game-model", - "output_text": response_content, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }) - } else { - serde_json::json!({ - "id": "chatcmpl_game_creator_mock", - "model": "mock-game-model", - "choices": [ - { - "message": { "content": response_content }, - "finish_reason": "stop" - } - ], - "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } - }) - } - .to_string(); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - stream - .write_all(response.as_bytes()) - .expect("mock llm response"); - } - }); - base_url - } - - fn spawn_mock_external_canvas_api_server() -> String { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock canvas api bind"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("mock canvas api addr") - ); - let signed_url = format!("{base_url}/signed/hero.png"); - let project_body = serde_json::json!({ - "project": { - "projectId": "canvas-project-1", - "title": "月光画板", - "resources": [ - { - "resourceId": "resource-1", - "projectId": "canvas-project-1", - "imageSrc": "/generated/canvas/hero.png", - "objectKey": "generated/canvas/hero.png", - "assetObjectId": "asset-object-1", - "width": 64, - "height": 64, - "sourceType": "generated", - "prompt": "像素月光主角", - "actualPrompt": "透明 PNG 像素月光主角", - "model": "gpt-image-2", - "provider": "vector-engine", - "taskId": "task-1", - "assetKind": "character" - } - ], - "updatedAt": "2026-06-25T00:00:00Z" - } - }) - .to_string(); - let generation_body = serde_json::json!({ - "imageSrc": "/generated/canvas/hero.png", - "objectKey": "generated/canvas/hero.png", - "assetObjectId": "asset-object-1", - "width": 64, - "height": 64, - "sourceType": "generated", - "prompt": "像素月光主角", - "actualPrompt": "透明 PNG 像素月光主角", - "model": "gpt-image-2", - "provider": "VectorEngine", - "taskId": "task-1", - "resource": { - "resourceId": "resource-1", - "projectId": "canvas-project-1", - "imageSrc": "/generated/canvas/hero.png", - "objectKey": "generated/canvas/hero.png", - "assetObjectId": "asset-object-1", - "width": 64, - "height": 64, - "sourceType": "generated", - "prompt": "像素月光主角", - "actualPrompt": "透明 PNG 像素月光主角", - "model": "gpt-image-2", - "provider": "VectorEngine", - "taskId": "task-1", - "assetKind": "character" - }, - "asset": { - "assetId": "asset-1", - "assetObjectId": "asset-object-1", - "assetKind": "character" - } - }) - .to_string(); - let read_body = serde_json::json!({ - "read": { - "provider": "aliyun-oss", - "bucket": "mock", - "endpoint": "mock", - "host": "mock", - "objectKey": "generated/canvas/hero.png", - "expiresAt": "2026-06-25T00:10:00Z", - "signedUrl": signed_url - } - }) - .to_string(); - std::thread::spawn(move || { - for _ in 0..3 { - let (mut stream, _) = listener.accept().expect("mock canvas api accept"); - let mut request_buffer = [0_u8; 8192]; - let read_len = stream.read(&mut request_buffer).unwrap_or(0); - let request = String::from_utf8_lossy(&request_buffer[..read_len]); - let normalized_request = request.to_ascii_lowercase(); - let (content_type, body) = if request - .starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ") - { - assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", project_body.as_bytes().to_vec()) - } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { - assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", generation_body.as_bytes().to_vec()) - } else if request.starts_with( - "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ", - ) { - assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", read_body.as_bytes().to_vec()) - } else if request.starts_with("GET /signed/hero.png ") { - ("image/png", b"fake-png".to_vec()) - } else { - ("text/plain", b"not found".to_vec()) - }; - let status = if content_type == "text/plain" { - "404 Not Found" - } else { - "200 OK" - }; - let response = format!( - "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - stream - .write_all(response.as_bytes()) - .expect("mock canvas api header"); - stream.write_all(&body).expect("mock canvas api body"); - } - }); - base_url - } - - fn spawn_mock_external_canvas_generation_failure_server() -> String { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock canvas api bind"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("mock canvas api addr") - ); - std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("mock canvas api accept"); - let mut request_buffer = [0_u8; 8192]; - let read_len = stream.read(&mut request_buffer).unwrap_or(0); - let request = String::from_utf8_lossy(&request_buffer[..read_len]); - assert!(request.starts_with("POST /api/external/v1/editor/images/generations ")); - assert!(request - .to_ascii_lowercase() - .contains("authorization: bearer ")); - let body = b"{\"error\":\"generation failed\"}"; - let response = format!( - "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - stream - .write_all(response.as_bytes()) - .expect("mock canvas api header"); - stream.write_all(body).expect("mock canvas api body"); - }); - base_url - } - - #[tokio::test] - async fn request_llm_game_draft_uses_openai_compatible_provider_output() { - let response_content = - serde_json::to_string(&fake_llm_game_draft()).expect("fake draft json"); - let base_url = spawn_mock_llm_server(response_content); - let config = LlmConfig::new( - LlmProvider::OpenAiCompatible, - base_url, - "test-key".to_string(), - "mock-game-model".to_string(), - GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, - 0, - DEFAULT_RETRY_BACKOFF_MS, - ) - .expect("llm config"); - let client = LlmClient::new(config).expect("llm client"); - - let draft = request_llm_game_draft_with_client( - &client, - "做一个月光厨房弹幕游戏", - "", - "# 项目长期记忆\n", - ) - .await - .expect("llm draft"); - - assert_eq!(draft.title, "月光弹幕厨房"); - assert!(draft - .game_html - .contains("MOCK_UNIQUE_MECHANIC:moon-kitchen-reflect")); - assert_eq!(draft.balance["source"], "llm"); - assert_eq!(draft.handoffs.len(), 6); - assert!(draft - .handoffs - .iter() - .any(|handoff| handoff.group == "publishing")); - } - - #[tokio::test] - async fn generate_local_game_draft_sends_asset_context_to_llm() { - let root = unique_project_path(); - let uploaded = upload_local_asset_at(&root, "../角色.png", "image/png", b"fake-png") - .expect("asset upload"); - append_local_conversation_message_at( - &root, - None, - LocalConversationMessage { - role: "user".to_string(), - content: "项目对话:主角必须挥舞月光锅铲".to_string(), - agent_id: None, - }, - ) - .expect("append project conversation"); - append_local_conversation_message_at( - &root, - Some("art-asset-plan"), - LocalConversationMessage { - role: "assistant".to_string(), - content: "API Key sk-unit-secret\nAgent 对话:美术要用蓝紫霓虹厨房".to_string(), - agent_id: None, - }, - ) - .expect("append agent conversation"); - let mut responses = vec![ - "## 核心循环\n\n用上传角色图做主角。\n\n## Evaluator 验收\n\n必须使用本地资产。" - .to_string(), - ]; - responses.push(serde_json::to_string(&fake_llm_game_draft()).expect("draft json")); - let (sender, receiver) = mpsc::channel(); - let base_url = spawn_mock_llm_server_responses_with_capture(responses, Some(sender)); - let _config_guard = write_test_local_config(format!( - r#"{{ - "llm": {{ - "apiKey": "test-key", - "baseUrl": {base_url:?}, - "model": "mock-game-model", - "apiKind": "openai_responses" - }} -}}"# - )); - - let result = generate_local_game_draft_at(&root, "用上传角色图做主角", None).await; - - result.expect("generated draft"); - - let requests = receiver.try_iter().collect::>(); - let planner_request = requests.first().expect("planner request"); - assert!(planner_request.contains("# 本地项目资产")); - assert!(planner_request.contains(&uploaded.local_path)); - assert!(planner_request.contains("source=uploaded")); - assert!(planner_request.contains("# 最近对话上下文")); - assert!(planner_request.contains("项目对话:主角必须挥舞月光锅铲")); - assert!(!planner_request.contains("Agent 对话:美术要用蓝紫霓虹厨房")); - assert!(!planner_request.contains("[redacted sensitive context]")); - assert!(!planner_request.contains("sk-unit-secret")); - let art_asset_brief = - fs::read_to_string(root.join(".agent/passes/pass-1/groups/art/asset.md")) - .expect("art asset brief"); - assert!(art_asset_brief.contains("Agent 对话:美术要用蓝紫霓虹厨房")); - assert!(art_asset_brief.contains("[redacted sensitive context]")); - assert!(!art_asset_brief.contains("sk-unit-secret")); - - fs::remove_dir_all(root).ok(); - } - - #[tokio::test] - async fn agent_loop_uses_per_agent_llm_overrides() { - let root = unique_project_path(); - init_local_game_project_at(&root, "local-project-draft", "未命名游戏原型") - .expect("init project"); - let (planner_sender, planner_receiver) = mpsc::channel(); - let planner_base_url = spawn_mock_llm_server_responses_with_capture( - vec!["## 核心循环\n\n反弹月光弹幕。\n\n## Evaluator 验收\n\n必须可玩。".to_string()], - Some(planner_sender), - ); - let (generator_sender, generator_receiver) = mpsc::channel(); - let generator_base_url = spawn_mock_llm_server_responses_with_capture( - vec![serde_json::to_string(&fake_llm_game_draft()).expect("draft json")], - Some(generator_sender), - ); - let (art_sender, art_receiver) = mpsc::channel(); - let art_base_url = spawn_mock_llm_server_responses_with_capture( - vec!["远程美术 Agent brief:生成月光锅铲主角和厨房弹幕素材。".to_string()], - Some(art_sender), - ); - let mut agent_llm = BTreeMap::new(); - agent_llm.insert( - "planner".to_string(), - GameCreatorLlmConfigFile { - api_key: Some("planner-key".to_string()), - base_url: Some(planner_base_url), - model: Some("planner-model".to_string()), - api_kind: Some("openai_responses".to_string()), - stream: Some(false), - request_timeout_ms: None, - max_retries: None, - retry_backoff_ms: None, - }, - ); - agent_llm.insert( - "generator".to_string(), - GameCreatorLlmConfigFile { - api_key: Some("generator-key".to_string()), - base_url: Some(generator_base_url), - model: Some("generator-model".to_string()), - api_kind: Some("openai_responses".to_string()), - stream: Some(false), - request_timeout_ms: None, - max_retries: None, - retry_backoff_ms: None, - }, - ); - agent_llm.insert( - "art-asset-plan".to_string(), - GameCreatorLlmConfigFile { - api_key: Some("art-key".to_string()), - base_url: Some(art_base_url), - model: Some("art-model".to_string()), - api_kind: Some("openai_responses".to_string()), - stream: Some(false), - request_timeout_ms: None, - max_retries: None, - retry_backoff_ms: None, - }, - ); - let app_config = GameCreatorAppConfig { - agent_llm, - ..GameCreatorAppConfig::default() - }; - - let loop_result = run_game_creator_agent_loop_at( - &root, - &app_config, - "做一个月光厨房弹幕游戏", - "", - "", - "", - None, - ) - .await - .expect("agent loop"); - - assert_eq!(loop_result.passes, 1); - assert_eq!(planner_receiver.try_iter().count(), 1); - assert_eq!(generator_receiver.try_iter().count(), 1); - assert_eq!(art_receiver.try_iter().count(), 1); - let art_brief = fs::read_to_string(root.join(".agent/passes/pass-1/groups/art/asset.md")) - .expect("art role brief"); - assert!(art_brief.contains("远程美术 Agent brief")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn llm_config_check_reports_status_without_leaking_key() { - let missing = check_game_creator_llm_config_values( - &GameCreatorLlmConfig { - api_key: String::new(), - ..GameCreatorLlmConfig::default() - }, - "llm", - ); - assert!(!missing.configured); - assert!(!missing.api_key_present); - assert!(missing.error.unwrap().contains("LLM 未配置")); - - let too_fast = check_game_creator_llm_config_values( - &GameCreatorLlmConfig { - api_key: "unit-test-api-key".to_string(), - base_url: "http://127.0.0.1:1/v1".to_string(), - model: "mock-game-model".to_string(), - request_timeout_ms: MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS - 1, - ..GameCreatorLlmConfig::default() - }, - "llm", - ); - assert!(!too_fast.configured); - assert!(too_fast.api_key_present); - assert!(too_fast - .error - .as_deref() - .expect("too fast error") - .contains("至少为 1000")); - assert!(!serde_json::to_string(&too_fast) - .unwrap() - .contains("unit-test-api-key")); - - let configured = check_game_creator_llm_config_values( - &GameCreatorLlmConfig { - api_key: "unit-test-api-key".to_string(), - base_url: "http://127.0.0.1:1/v1".to_string(), - model: "mock-game-model".to_string(), - ..GameCreatorLlmConfig::default() - }, - "llm", - ); - assert!(configured.configured); - assert!(configured.api_key_present); - assert_eq!( - configured.base_url.as_deref(), - Some("http://127.0.0.1:1/v1") - ); - assert_eq!(configured.model.as_deref(), Some("mock-game-model")); - assert_eq!(configured.api_kind, "openai_responses"); - assert!(!serde_json::to_string(&configured) - .unwrap() - .contains("unit-test-api-key")); - } - - #[test] - fn llm_config_check_reports_per_agent_status_without_leaking_keys() { - let root = unique_project_path(); - fs::create_dir_all(&root).expect("runtime config dir"); - let _guard = use_test_runtime_config_dir(root.clone()); - fs::write( - root.join(GAME_CREATOR_CONFIG_FILE_NAME), - r#"{ - "llm": { - "apiKey": "", - "baseUrl": "https://global.example.test/v1", - "model": "global-model" - }, - "agentLlm": { - "planner": { - "apiKey": "planner-secret-key", - "baseUrl": "https://planner.example.test/v1", - "model": "planner-model", - "apiKind": "anthropic" - }, - "generator": { - "apiKey": "generator-secret-key", - "baseUrl": "https://generator.example.test/v1", - "model": "generator-model", - "apiKind": "openai_chat" - }, - "art-asset-plan": { - "apiKey": "art-secret-key", - "baseUrl": "https://art.example.test/v1", - "model": "art-model", - "apiKind": "openai_chat" - } - } -} -"#, - ) - .expect("write runtime config"); - - let status = check_game_creator_llm_config_from_config(); - - assert!(status.configured); - assert!(!status.api_key_present); - assert!(status.agents.len() > 2); - let planner = status - .agents - .iter() - .find(|agent| agent.agent_id == "planner") - .expect("planner status"); - assert!(planner.configured); - assert!(planner.api_key_present); - assert_eq!(planner.model.as_deref(), Some("planner-model")); - assert_eq!(planner.api_kind, "anthropic"); - let generator = status - .agents - .iter() - .find(|agent| agent.agent_id == "generator") - .expect("generator status"); - assert!(generator.configured); - assert_eq!( - generator.base_url.as_deref(), - Some("https://generator.example.test/v1") - ); - let art = status - .agents - .iter() - .find(|agent| agent.agent_id == "art-asset-plan") - .expect("art agent status"); - assert!(art.configured); - assert_eq!(art.label, "美术组 / Asset"); - assert_eq!(art.model.as_deref(), Some("art-model")); - let serialized = serde_json::to_string(&status).expect("status json"); - assert!(!serialized.contains("planner-secret-key")); - assert!(!serialized.contains("generator-secret-key")); - assert!(!serialized.contains("art-secret-key")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn llm_config_check_reports_agent_specific_config_paths() { - let root = unique_project_path(); - fs::create_dir_all(&root).expect("runtime config dir"); - let _guard = use_test_runtime_config_dir(root.clone()); - fs::write( - root.join(GAME_CREATOR_CONFIG_FILE_NAME), - r#"{ - "llm": { - "apiKey": "global-key", - "baseUrl": "https://global.example.test/v1", - "model": "global-model" - }, - "agentLlm": { - "generator": { - "apiKey": "", - "baseUrl": "https://generator.example.test/v1", - "model": "generator-model" - } - } -} -"#, - ) - .expect("write runtime config"); - - let status = check_game_creator_llm_config_from_config(); - - assert!(!status.configured); - let generator = status - .agents - .iter() - .find(|agent| agent.agent_id == "generator") - .expect("generator status"); - let error = generator.error.as_deref().expect("generator error"); - assert!(error.contains("agentLlm.generator.apiKey")); - assert!(!serde_json::to_string(&status) - .expect("status json") - .contains("global-key")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { - let status = GameCreatorLlmConfigStatus { - configured: false, - api_key_present: false, - base_url: Some("https://global.example.test/v1".to_string()), - model: Some("global-model".to_string()), - api_kind: "openai_responses".to_string(), - stream: false, - error: Some("Generator:缺少 API Key".to_string()), - agents: vec![GameCreatorAgentLlmConfigStatus { - agent_id: "generator".to_string(), - label: "Generator".to_string(), - configured: false, - api_key_present: false, - base_url: Some("https://generator.example.test/v1".to_string()), - model: Some("generator-model".to_string()), - api_kind: "openai_chat".to_string(), - stream: true, - error: Some( - "LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key".to_string(), - ), - }], - }; - - let lines = game_creator_llm_status_lines(&status).join("\n"); - - assert!(lines.contains("llm.agent.generator.error=LLM 未配置")); - assert!(lines.contains("llm.agent.generator.stream=true")); - assert!(lines.contains("llm.error=Generator:缺少 API Key")); - assert!(!lines.contains("sk-")); - assert!(!lines.contains("secret")); - } - - #[test] - fn llm_api_kind_parses_canonical_names() { - assert_eq!( - parse_game_creator_llm_api_kind("anthropic"), - Ok(LlmApiKind::Anthropic) - ); - assert_eq!( - parse_game_creator_llm_api_kind("openai_chat"), - Ok(LlmApiKind::OpenAiChat) - ); - assert_eq!( - parse_game_creator_llm_api_kind("openai_responses"), - Ok(LlmApiKind::OpenAiResponses) - ); - assert_eq!( - parse_game_creator_llm_api_kind(""), - Ok(LlmApiKind::OpenAiResponses) - ); - assert!(parse_game_creator_llm_api_kind("legacy").is_err()); - } - - #[tokio::test] - async fn agent_loop_writes_spec_findings_and_retries_generator() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); - let mut first_draft = fake_llm_game_draft(); - first_draft.game_html = r#" - - - -

目标:反弹月光弹幕点亮三口锅。胜利 / 失败后按 R 重开。

- - -"# - .to_string(); - let fixed_draft = fake_llm_game_draft(); - let mut responses = vec![ - "## 核心循环\n\n反弹月光弹幕点亮三口锅。\n\n## Evaluator 验收\n\n必须有输入监听。" - .to_string(), - ]; - responses.push(serde_json::to_string(&first_draft).expect("first draft json")); - responses.push(serde_json::to_string(&fixed_draft).expect("fixed draft json")); - let base_url = spawn_mock_llm_server_responses(responses); - let app_config = GameCreatorAppConfig { - llm: GameCreatorLlmConfig { - api_key: "test-key".to_string(), - base_url, - model: "mock-game-model".to_string(), - ..GameCreatorLlmConfig::default() - }, - ..GameCreatorAppConfig::default() - }; - - let mut loop_result = run_game_creator_agent_loop_at( - &root, - &app_config, - "做一个月光厨房弹幕游戏", - "", - "# 项目长期记忆\n", - "", - None, - ) - .await - .expect("agent loop"); - let result = write_local_game_draft_at(&root, "做一个月光厨房弹幕游戏", &loop_result.draft) - .expect("write draft"); - append_local_artifact_write_step(&root, "做一个月光厨房弹幕游戏", &mut loop_result) - .expect("artifact write trace"); - append_static_smoke_step(&root, "做一个月光厨房弹幕游戏", &mut loop_result) - .expect("static smoke trace"); - append_agent_loop_log(&root, &loop_result).expect("loop log"); - - assert_eq!(loop_result.passes, 2); - assert_eq!(result.project_path, root.to_string_lossy().into_owned()); - let spec = fs::read_to_string(root.join(".agent/spec.md")).expect("spec"); - assert!(spec.contains("# Planner Spec")); - assert!(spec.contains("反弹月光弹幕")); - let findings = fs::read_to_string(root.join(".agent/findings.md")).expect("findings"); - assert!(findings.contains("pass: 2")); - assert!(findings.contains("status: passed")); - let game_html = fs::read_to_string(root.join("game/index.html")).expect("game html"); - assert!(game_html.contains("MOCK_UNIQUE_MECHANIC:moon-kitchen-reflect")); - let agent_log = fs::read_to_string(root.join(".agent/logs/agent.log")).expect("agent log"); - assert!(agent_log.contains("agent.loop passes=2")); - assert!(agent_log.contains("Planner -> .agent/spec.md")); - assert!(agent_log.contains("组内角色 briefs -> .agent/passes/pass-*/groups//*.md")); - assert!(agent_log.contains("专业组汇总 -> .agent/passes/pass-*/groups/*.md")); - assert!(agent_log.contains("Generator -> .agent/passes/pass-*/draft.json")); - assert!(agent_log.contains("专业组 handoffs -> .agent/passes/pass-*/handoff.md")); - assert!(agent_log.contains("Evaluator -> .agent/findings.md")); - let session_memory = - fs::read_to_string(root.join("memory/session.md")).expect("session memory"); - assert!(session_memory.contains("## Agent Run game-generate-draft-")); - assert!(session_memory.contains("状态:passed;loop:2/3;下一步:preview-playtest")); - assert!(session_memory.contains("activeTasks:")); - assert!(session_memory.contains("carryOverTasks:")); - assert!(session_memory.contains("game/index.html")); - let project_memory = - fs::read_to_string(root.join("memory/project.md")).expect("project memory"); - assert!(project_memory.contains("## 最近稳定原型")); - assert!(project_memory.contains("月光弹幕厨房")); - assert!(project_memory.contains("通过轮次:2/3")); - assert!(project_memory.contains("可试玩入口:game/index.html")); - let blackboard = - fs::read_to_string(root.join(PROJECT_BLACKBOARD_MEMORY_PATH)).expect("blackboard"); - assert!(blackboard.contains("# 项目黑板")); - assert!(blackboard.contains("## pass 2 - 月光弹幕厨房")); - assert!(blackboard.contains("程序组 / Code")); - for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { - for role in group.roles { - let memory_path = agent_role_memory_relative_path(group, *role); - let memory = fs::read_to_string(root.join(&memory_path)).unwrap_or_else(|error| { - panic!("read agent memory {memory_path}: {error}"); - }); - assert!(memory.contains(&format!( - "# Agent 私有记忆 - {} / {}", - group.label, role.role - ))); - assert!(memory.contains("月光弹幕厨房")); - } - } - let first_design_director = - fs::read_to_string(root.join(".agent/passes/pass-1/groups/design/director.md")) - .expect("design director brief"); - assert!(first_design_director.contains("策划组 / Director")); - assert!(first_design_director.contains("本角色判断:pass 1")); - assert!(first_design_director.contains("策划组 / Director")); - let first_design_brief = - fs::read_to_string(root.join(".agent/passes/pass-1/groups/design.md")) - .expect("design brief"); - assert!(first_design_brief.contains("策划组 / Director")); - assert!(first_design_brief.contains("策划组 / Gameplay")); - let first_handoff = - fs::read_to_string(root.join(".agent/passes/pass-1/handoff.md")).expect("handoff"); - assert!(first_handoff.contains("策划组 / Gameplay")); - assert!(first_handoff.contains("程序组 / Code")); - assert!(first_handoff.contains("outputs: game/game_design.md")); - assert!(first_handoff.contains("next: 交给数值和程序组")); - let first_agenda = - fs::read_to_string(root.join(".agent/passes/pass-1/agenda.md")).expect("agenda 1"); - assert!(first_agenda.contains("mode: initial")); - assert!(first_agenda.contains("activeTasks: design-director")); - assert!(first_agenda.contains("wave 1: design-director")); - assert!(first_agenda.contains("wave 12: publish-package")); - let first_task_graph: Value = serde_json::from_str( - &fs::read_to_string(root.join(".agent/passes/pass-1/task-graph.json")) - .expect("task graph 1"), - ) - .expect("task graph json 1"); - assert_eq!( - first_task_graph["schemaVersion"], - "game-creator-agent-pass-task-graph.v1" - ); - assert_eq!(first_task_graph["dependencyWaves"][0][0], "design-director"); - let second_agenda = - fs::read_to_string(root.join(".agent/passes/pass-2/agenda.md")).expect("agenda 2"); - assert!(second_agenda.contains("mode: repair")); - assert!(second_agenda.contains("activeTasks: code-director")); - assert!(second_agenda.contains("carriedTasks: design-director")); - let second_task_graph: Value = serde_json::from_str( - &fs::read_to_string(root.join(".agent/passes/pass-2/task-graph.json")) - .expect("task graph 2"), - ) - .expect("task graph json 2"); - assert!(second_task_graph["dependencyWaves"] - .as_array() - .unwrap() - .iter() - .any(|wave| wave - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "code-prototype"))); - assert_eq!( - second_task_graph["repairRoutes"][0]["reason"], - "code-runtime+dependency-impact" - ); - assert!(second_task_graph["repairRoutes"][0]["taskIds"] - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "preview-readiness")); - assert!(second_task_graph["repairRoutes"][0]["taskIds"] - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "publish-package")); - let trace: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) - .expect("run trace json"); - assert_eq!( - trace["schemaVersion"], - GAME_CREATION_AGENT_RUN_SCHEMA_VERSION - ); - assert_eq!(trace["status"], "passed"); - assert_eq!(trace["passes"], 2); - assert_eq!( - trace["maxPasses"], - serde_json::json!(GAME_CREATOR_AGENT_LOOP_MAX_PASSES) - ); - assert_eq!( - trace["maxToolCalls"], - serde_json::json!(GAME_CREATOR_AGENT_TOOL_CALL_MAX) - ); - let expected_tool_call_count: usize = trace["steps"] - .as_array() - .unwrap() - .iter() - .map(|step| step["toolCalls"].as_array().unwrap().len()) - .sum(); - assert_eq!( - trace["toolCallCount"], - serde_json::json!(expected_tool_call_count) - ); - assert_eq!(trace["stopReason"], "evaluator-passed"); - assert_eq!(trace["coordination"], "filesystem"); - assert_eq!(trace["nextStep"], "preview-playtest"); - assert_eq!(trace["taskGraph"]["goal"], "做一个月光厨房弹幕游戏"); - assert!(trace["taskGraph"]["activeTaskIds"] - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "code-director")); - assert!(trace["taskGraph"]["carriedTaskIds"] - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "design-director")); - assert!(trace["taskGraph"]["repairFocus"] - .as_array() - .unwrap() - .iter() - .any(|issue| issue - .as_str() - .is_some_and(|issue| issue.contains("输入监听")))); - assert!(trace["taskGraph"]["repairRoutes"] - .as_array() - .unwrap() - .iter() - .any(|route| route["reason"] == "code-runtime+dependency-impact" - && route["taskIds"] - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "code-prototype"))); - assert!(trace["taskGraph"]["tasks"] - .as_array() - .unwrap() - .iter() - .any(|task| task["id"] == "preview-readiness" && task["status"] == "completed")); - assert!(trace["taskGraph"]["tasks"] - .as_array() - .unwrap() - .iter() - .any(|task| task["id"] == "preview-playtest" - && task["status"] == "waiting-for-confirmation")); - assert_eq!(trace["passPlans"].as_array().unwrap().len(), 2); - assert_eq!(trace["passPlans"][0]["mode"], "initial"); - assert_eq!( - trace["passPlans"][0]["summary"], - first_task_graph["summary"] - ); - assert_eq!( - trace["passPlans"][0]["dependencyWaves"][0][0], - "design-director" - ); - assert_eq!(trace["passPlans"][1]["mode"], "repair"); - assert!(trace["passPlans"][1]["activeTaskIds"] - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "code-prototype")); - assert!(trace["passPlans"][1]["activeTaskIds"] - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "publish-package")); - assert!(trace["passPlans"][1]["carriedTaskIds"] - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "design-director")); - assert!(trace["passPlans"][1]["dependencyWaves"] - .as_array() - .unwrap() - .iter() - .any(|wave| wave - .as_array() - .unwrap() - .iter() - .any(|task_id| task_id == "code-prototype"))); - let steps = trace["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 66); - assert_eq!(steps[0]["agent"], "Planner"); - assert_eq!(steps[0]["phase"], "planning"); - assert_eq!(steps[0]["taskId"], "design-director"); - assert_eq!(steps[0]["toolCalls"][0]["toolId"], "llm.chat.planner"); - assert!(steps[0]["inputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == ".agent/manifest.json")); - assert!(steps[0]["inputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == PROJECT_BLACKBOARD_MEMORY_PATH)); - assert!(steps.iter().any(|step| step["agent"] == "Orchestrator" - && step["pass"].as_u64() == Some(1) - && step["toolCalls"][0]["toolId"] == "agent.task_graph.plan_pass" - && step["outputPaths"][0] == ".agent/passes/pass-1/agenda.md")); - assert!(steps.iter().any(|step| step["agent"] == "Orchestrator" - && step["pass"].as_u64() == Some(2) - && step["summary"] - .as_str() - .is_some_and(|summary| summary.contains("carry-over")))); - assert!(steps.iter().any(|step| step["agent"] == "策划组 / Director" - && step["phase"] == "role-brief" - && step["taskId"] == "design-director" - && step["group"] == "design" - && step["role"] == "Director" - && step["toolCalls"][0]["toolId"] == "agent.role.brief.design.director" - && step["inputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == ".agent/passes/pass-1/agenda.md") - && step["inputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == "memory/agents/design/director.md") - && step["outputPaths"][0] == ".agent/passes/pass-1/groups/design/director.md" - && step["outputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == PROJECT_BLACKBOARD_MEMORY_PATH) - && step["outputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == "memory/agents/design/director.md"))); - assert!(steps.iter().any(|step| step["agent"] == "策划组 / Director" - && step["pass"].as_u64() == Some(2) - && step["status"] == "carried-over" - && step["phase"] == "role-brief" - && step["toolCalls"][0]["toolId"] == "agent.task_graph.carryover.design.director" - && step["outputPaths"][0] == ".agent/passes/pass-2/groups/design/director.md")); - assert!(steps.iter().any(|step| step["agent"] == "程序组 / Director" - && step["pass"].as_u64() == Some(2) - && step["status"] == "completed" - && step["toolCalls"][0]["toolId"] == "agent.role.brief.code.director")); - assert!(steps.iter().any(|step| step["agent"] == "运营组 / Publish" - && step["pass"].as_u64() == Some(2) - && step["status"] == "completed" - && step["toolCalls"][0]["toolId"] == "agent.role.brief.publishing.publish")); - assert!(steps - .iter() - .any(|step| step["agent"] == "策划组 / GroupCoordinator" - && step["toolCalls"][0]["toolId"] == "agent.group.aggregate.design" - && step["outputPaths"][0] == ".agent/passes/pass-1/groups/design.md")); - assert!(steps.iter().any(|step| { - step["agent"] == "Generator" - && step["toolCalls"][0]["toolId"] == "llm.chat.generator" - && step["inputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == ".agent/manifest.json") - && step["inputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == PROJECT_BLACKBOARD_MEMORY_PATH) - && step["inputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == ".agent/passes/pass-1/groups/design.md") - })); - assert!(steps.iter().any(|step| { - step["agent"] == "Generator" - && step["pass"].as_u64() == Some(2) - && step["inputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == ".agent/passes/pass-2/agenda.md") - })); - assert!(steps.iter().any(|step| step["agent"] == "策划组 / Gameplay" - && step["phase"] == "handoff" - && step["taskId"] == "design-foundation" - && step["toolCalls"][0]["toolId"] == "agent.handoff.design" - && step["summary"] == "定义反弹循环")); - assert!(steps - .iter() - .any(|step| step["agent"] == "数值组 / Difficulty")); - assert!(steps.iter().any(|step| step["agent"] == "美术组 / Asset")); - assert!(steps.iter().any(|step| step["agent"] == "美术组 / Asset" - && step["toolCalls"] - .as_array() - .unwrap() - .iter() - .any( - |tool_call| tool_call["toolId"] == "agent.tool.suggest.canvas.project_sync" - && tool_call["status"] == "suggested" - ))); - assert!(steps.iter().any(|step| step["agent"] == "音乐组 / SFX")); - assert!(steps.iter().any(|step| step["agent"] == "音乐组 / SFX" - && step["toolCalls"] - .as_array() - .unwrap() - .iter() - .any( - |tool_call| tool_call["toolId"] == "agent.tool.suggest.canvas.project_sync" - && tool_call["summary"] - .as_str() - .is_some_and(|summary| summary.contains("/sync-canvas-project")) - ))); - assert!(steps.iter().any(|step| step["agent"] == "程序组 / Code")); - assert!(steps.iter().any(|step| step["agent"] == "运营组 / Publish")); - assert!(steps - .iter() - .any(|step| step["agent"] == "Evaluator" && step["status"] == "needs-revision")); - assert!(steps - .iter() - .any(|step| step["agent"] == "Evaluator" && step["status"] == "passed")); - let playtest = steps - .iter() - .find(|step| step["agent"] == "Playtest") - .expect("playtest step"); - assert_eq!(playtest["toolCalls"][0]["toolId"], "game.static_smoke"); - let artifact_writer = steps - .iter() - .find(|step| step["agent"] == "ArtifactWriter") - .expect("artifact writer step"); - assert_eq!( - artifact_writer["toolCalls"][0]["toolId"], - "file.write.local_artifacts" - ); - assert!(artifact_writer["outputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == "game/index.html")); - let artifacts = trace["artifacts"].as_array().unwrap(); - let game_artifact = artifacts - .iter() - .find(|artifact| artifact["path"] == "game/index.html") - .expect("game artifact"); - assert!(game_artifact["sizeBytes"].as_u64().unwrap() > 0); - assert!(game_artifact["checksum"] - .as_str() - .unwrap() - .starts_with("fnv1a64:")); - assert!(artifacts - .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-1/game.html")); - assert!(artifacts - .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-1/agenda.md")); - assert!(artifacts - .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-1/task-graph.json")); - assert!(artifacts - .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-2/agenda.md")); - assert!(artifacts - .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-2/task-graph.json")); - assert!(artifacts - .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-1/groups/design/director.md")); - assert!(artifacts - .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-1/groups/design.md")); - assert!(artifacts - .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-2/handoff.md")); - let run_history_path = root.join(format!( - ".agent/runs/{}.json", - trace["runId"].as_str().unwrap() - )); - let run_history: Value = - serde_json::from_str(&fs::read_to_string(run_history_path).unwrap()) - .expect("run history json"); - assert_eq!(run_history["runId"], trace["runId"]); - assert_eq!(run_history["status"], "passed"); - assert_eq!(run_history["stopReason"], "evaluator-passed"); - assert_eq!(run_history["steps"].as_array().unwrap().len(), 66); - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert!(manifest["commandRuns"] - .as_array() - .unwrap() - .iter() - .any(|run| run["commandId"] == "game.static_smoke")); - - fs::remove_dir_all(root).ok(); - } - - #[tokio::test] - async fn generate_local_game_draft_fails_after_max_passes_without_final_artifacts() { - let root = unique_project_path(); - let mut invalid_draft = fake_llm_game_draft(); - invalid_draft.game_html = r#" - - - -

目标:点亮厨房。胜利 / 失败后按 R 重开。

- - -"# - .to_string(); - let invalid_draft_json = serde_json::to_string(&invalid_draft).expect("invalid draft json"); - let mut responses = vec!["## 核心循环\n\n点亮厨房,但必须通过 Evaluator。".to_string()]; - responses.push(invalid_draft_json.clone()); - responses.push(invalid_draft_json.clone()); - responses.push(invalid_draft_json); - let base_url = spawn_mock_llm_server_responses(responses); - let _config_guard = write_test_local_config(format!( - r#"{{ - "llm": {{ - "apiKey": "test-key", - "baseUrl": {base_url:?}, - "model": "mock-game-model", - "apiKind": "openai_responses" - }} -}}"# - )); - - let error = generate_local_game_draft_at(&root, "做一个会失败三轮的厨房游戏", None) - .await - .expect_err("max-pass failure should bubble out"); - - assert!(error.contains("已重试")); - assert!(error.contains(&GAME_CREATOR_AGENT_LOOP_MAX_PASSES.to_string())); - assert!(!root.join("memory/session.md").exists()); - assert!(!root.join("memory/project.md").exists()); - assert!(!root.join("game/game_design.md").exists()); - assert!(!root.join("game/balance.json").exists()); - assert!(!root.join("assets/manifest.art.json").exists()); - assert!(!root.join("assets/manifest.audio.json").exists()); - assert!(!root.join("exports/README.md").exists()); - let game_html = - fs::read_to_string(root.join("game/index.html")).expect("default game html"); - assert!(!game_html.contains("MOCK_UNIQUE_MECHANIC:moon-kitchen-reflect")); - assert!(!game_html.contains("点亮厨房")); - let trace: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) - .expect("run trace json"); - assert_eq!(trace["status"], "failed"); - assert_eq!( - trace["passes"], - serde_json::json!(GAME_CREATOR_AGENT_LOOP_MAX_PASSES) - ); - assert_eq!(trace["stopReason"], "max-passes-exhausted"); - assert_eq!(trace["nextStep"], "inspect-error"); - assert!(trace["error"] - .as_str() - .is_some_and(|message| message.contains("Evaluator"))); - assert!( - trace["passPlans"].as_array().unwrap().len() - == usize::from(GAME_CREATOR_AGENT_LOOP_MAX_PASSES) - ); - assert!(trace["artifacts"] - .as_array() - .unwrap() - .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-3/game.html")); - assert!(!trace["steps"] - .as_array() - .unwrap() - .iter() - .any(|step| step["agent"] == "ArtifactWriter")); - assert!(!trace["steps"] - .as_array() - .unwrap() - .iter() - .any(|step| step["agent"] == "Playtest")); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - let records = agent_db - .lines() - .map(|line| serde_json::from_str::(line).expect("agent db record")) - .collect::>(); - assert_eq!(records.len(), 1); - assert_eq!(records[0]["recordType"], "project.init"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn canvas_sync_suggestion_is_media_type_aware() { - let art_group = GAME_CREATOR_AGENT_GROUP_DEFINITIONS - .iter() - .find(|definition| definition.id == "art") - .copied() - .expect("art group"); - let audio_group = GAME_CREATOR_AGENT_GROUP_DEFINITIONS - .iter() - .find(|definition| definition.id == "audio") - .copied() - .expect("audio group"); - let art_role = ART_AGENT_ROLES - .iter() - .find(|role| role.id == "asset") - .copied() - .expect("art asset role"); - let audio_role = AUDIO_AGENT_ROLES - .iter() - .find(|role| role.id == "sfx") - .copied() - .expect("audio sfx role"); - let art_brief = AgentRoleBrief { - group_definition: art_group, - role_definition: art_role, - markdown: String::new(), - relative_path: ".agent/passes/pass-1/groups/art/asset.md".to_string(), - memory_relative_path: agent_role_memory_relative_path(art_group, art_role), - status: "completed".to_string(), - tool_id: art_role.tool_id.to_string(), - summary: String::new(), - }; - let audio_brief = AgentRoleBrief { - group_definition: audio_group, - role_definition: audio_role, - markdown: String::new(), - relative_path: ".agent/passes/pass-1/groups/audio/sfx.md".to_string(), - memory_relative_path: agent_role_memory_relative_path(audio_group, audio_role), - status: "completed".to_string(), - tool_id: audio_role.tool_id.to_string(), - summary: String::new(), - }; - let input_paths = vec![".agent/manifest.json".to_string()]; - let image_canvas_assets = vec!["image/png".to_string()]; - let audio_canvas_assets = vec!["audio/wav".to_string()]; - - assert!( - suggested_canvas_tool_call(&art_brief, &input_paths, &image_canvas_assets).is_none() - ); - assert!( - suggested_canvas_tool_call(&audio_brief, &input_paths, &image_canvas_assets).is_some() - ); - assert!( - suggested_canvas_tool_call(&audio_brief, &input_paths, &audio_canvas_assets).is_none() - ); - assert!( - suggested_canvas_tool_call(&art_brief, &input_paths, &audio_canvas_assets).is_some() - ); - } - - #[test] - fn init_local_game_project_creates_manifest_and_dirs() { - let root = unique_project_path(); - - let result = - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - assert_eq!(result.project_path, root.to_string_lossy().into_owned()); - assert!(root.join("game").is_dir()); - assert!(root.join("assets").is_dir()); - assert!(root.join("memory").is_dir()); - assert!(root.join("exports").is_dir()); - assert!(root.join(".agent/logs").is_dir()); - assert!(root.join(".agent/agent.db").is_file()); - assert!(root.join("game/index.html").is_file()); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - let first_record: Value = - serde_json::from_str(agent_db.lines().next().unwrap()).expect("agent db record"); - assert_eq!( - first_record["schemaVersion"], - GAME_CREATOR_AGENT_DB_SCHEMA_VERSION - ); - assert_eq!(first_record["recordType"], "project.init"); - assert_eq!(first_record["projectId"], "project-1"); - - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["projectId"], "project-1"); - assert_eq!(manifest["name"], "像素动作原型"); - assert_eq!(manifest["assets"].as_array().unwrap().len(), 0); - assert_eq!(manifest["tasks"].as_array().unwrap().len(), 16); - assert_eq!(manifest["tasks"][0]["id"], "design-director"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn init_local_game_project_requires_absolute_path() { - let error = init_local_game_project_at(Path::new("relative-game"), "project-1", "demo") - .expect_err("relative path should fail"); - - assert!(error.contains("绝对路径")); - } - - #[test] - fn project_directory_commands_reject_control_characters() { - let project_path = format!("{}\nnext", unique_project_path().display()); - - assert!(is_local_project_directory_non_empty(project_path.clone()) - .expect_err("non-empty check should reject control characters") - .contains("控制字符")); - assert!(inspect_local_project_directory(project_path.clone()) - .expect_err("directory inspect should reject control characters") - .contains("控制字符")); - assert!( - init_local_game_project_at(Path::new(&project_path), "project-1", "demo") - .expect_err("project init should reject control characters") - .contains("控制字符") - ); - } - - #[test] - fn project_directory_non_empty_check_reports_existing_content() { - let root = unique_project_path(); - assert!( - !is_local_project_directory_non_empty(root.to_string_lossy().to_string()) - .expect("missing dir should be empty") - ); - - fs::create_dir_all(&root).expect("project dir"); - assert!( - !is_local_project_directory_non_empty(root.to_string_lossy().to_string()) - .expect("empty dir should be empty") - ); - - fs::write(root.join("old.txt"), "existing").expect("existing file"); - assert!( - is_local_project_directory_non_empty(root.to_string_lossy().to_string()) - .expect("non-empty dir should be reported") - ); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn project_directory_status_distinguishes_missing_file_and_dir() { - let root = unique_project_path(); - let missing = inspect_local_project_directory(root.to_string_lossy().to_string()) - .expect("missing status"); - assert_eq!( - missing, - LocalProjectDirectoryStatus { - project_path: root.to_string_lossy().into_owned(), - exists: false, - is_directory: false, - is_game_creator_project: false, - project_name: None, - manifest_error: None, - recent_run_status: None, - recent_run_stop_reason: None, - } - ); - - fs::write(&root, "not a dir").expect("file"); - let file_status = inspect_local_project_directory(root.to_string_lossy().to_string()) - .expect("file status"); - assert!(file_status.exists); - assert!(!file_status.is_directory); - assert!(!file_status.is_game_creator_project); - assert_eq!(file_status.project_name, None); - assert_eq!(file_status.manifest_error, None); - assert_eq!(file_status.recent_run_status, None); - fs::remove_file(&root).expect("remove file"); - - fs::create_dir_all(&root).expect("dir"); - let dir_status = inspect_local_project_directory(root.to_string_lossy().to_string()) - .expect("dir status"); - assert!(dir_status.exists); - assert!(dir_status.is_directory); - assert!(!dir_status.is_game_creator_project); - assert_eq!(dir_status.project_name, None); - assert_eq!(dir_status.manifest_error, None); - assert_eq!(dir_status.recent_run_status, None); - - fs::create_dir_all(root.join(".agent")).expect("agent dir"); - fs::write(root.join(".agent/manifest.json"), "{broken").expect("broken manifest"); - let broken_manifest_status = - inspect_local_project_directory(root.to_string_lossy().to_string()) - .expect("broken manifest status"); - assert!(broken_manifest_status.exists); - assert!(broken_manifest_status.is_directory); - assert!(!broken_manifest_status.is_game_creator_project); - assert!(broken_manifest_status - .manifest_error - .as_deref() - .is_some_and(|error| error.contains("解析 manifest 失败"))); - fs::remove_file(root.join(".agent/manifest.json")).expect("remove broken manifest"); - - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - fs::write( - root.join(".agent/run.latest.json"), - serde_json::json!({ - "schemaVersion": "game-creator-agent-run.v1", - "runId": "run-1", - "commandId": "game.generate_draft", - "status": "failed", - "passes": 2, - "goal": "demo", - "coordination": "demo", - "steps": [], - "artifacts": [], - "nextStep": "fix", - "error": null, - "updatedAt": 1, - "stopReason": "max-passes-exhausted" - }) - .to_string(), - ) - .expect("run trace"); - let project_status = inspect_local_project_directory(root.to_string_lossy().to_string()) - .expect("project status"); - assert!(project_status.exists); - assert!(project_status.is_directory); - assert!(project_status.is_game_creator_project); - assert_eq!( - project_status.project_name, - Some("像素动作原型".to_string()) - ); - assert_eq!(project_status.manifest_error, None); - assert_eq!(project_status.recent_run_status, Some("failed".to_string())); - assert_eq!( - project_status.recent_run_stop_reason, - Some("max-passes-exhausted".to_string()) - ); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_project_directory_open_path_requires_existing_absolute_directory() { - let root = unique_project_path(); - assert!(validated_local_project_directory_path("relative-game").is_err()); - assert!(validated_local_project_directory_path(&root.to_string_lossy()).is_err()); - - fs::write(&root, "not a dir").expect("file"); - assert!(validated_local_project_directory_path(&root.to_string_lossy()).is_err()); - fs::remove_file(&root).expect("remove file"); - - fs::create_dir_all(&root).expect("dir"); - assert_eq!( - validated_local_project_directory_path(&root.to_string_lossy()).expect("valid dir"), - root - ); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn generate_local_game_draft_writes_memory_design_and_game() { - let root = unique_project_path(); - let draft = fake_llm_game_draft(); - let result = write_local_game_draft_at(&root, "像素风横版动作 - -"# - .to_string(); - - let error = validate_llm_game_draft("像素厨房弹幕", &draft) - .expect_err("missing goal and terminal states should fail"); - - assert!(error.contains("必须展示明确目标")); - } - - #[test] - fn validate_llm_game_draft_accepts_win_condition_as_goal() { - let mut draft = fake_llm_game_draft(); - draft.game_html = r#" - - - -

三连击获胜,生命耗尽失败,按 R 重开。

- - -"# - .to_string(); - - validate_llm_game_draft("像素厨房弹幕", &draft) - .expect("win condition should count as a clear goal"); - } - - #[test] - fn validate_llm_game_draft_rejects_empty_input_handler() { - let mut draft = fake_llm_game_draft(); - draft.game_html = r#" - - - -

目标:点亮厨房。胜利 / 失败后按 R 重开。

- - -"# - .to_string(); - - let error = validate_llm_game_draft("像素厨房弹幕", &draft) - .expect_err("empty input listener should fail"); - - assert!(error.contains("输入监听不能是空实现")); - } - - #[test] - fn validate_llm_game_draft_rejects_fixed_placeholder_template_terms() { - let mut draft = fake_llm_game_draft(); - draft.game_html = draft.game_html.replace("月光弹幕厨房", "星核传送门"); - - let error = validate_llm_game_draft("像素厨房弹幕", &draft) - .expect_err("fixed placeholder template should fail"); - - assert!(error.contains("固定模板或未完成实现")); - } - - #[test] - fn validate_llm_game_draft_requires_canvas_drawing() { - let mut draft = fake_llm_game_draft(); - draft.game_html = r#" - - - -

目标:点亮厨房。胜利 / 失败后按 R 重开。

- - -"# - .to_string(); - - let error = - validate_llm_game_draft("像素厨房弹幕", &draft).expect_err("blank canvas should fail"); - - assert!(error.contains("canvas 上绘制画面")); - } - - #[test] - fn validate_llm_game_draft_rejects_forbidden_runtime_apis() { - let mut draft = fake_llm_game_draft(); - draft.game_html = draft - .game_html - .replace("const marker =", "fetch('/secret');\n const marker ="); - - let error = validate_llm_game_draft("像素厨房弹幕", &draft) - .expect_err("fetch should fail validation"); - - assert!(error.contains("fetch(")); - } - - #[test] - fn evaluator_findings_include_structured_repair_routes() { - let findings = - render_evaluator_findings(1, &["LLM 草案 gameHtml 必须包含游戏主循环和输入监听"]); - - assert!(findings.contains("## Repair Routes")); - assert!(findings.contains("\"taskIds\"")); - assert!(findings.contains("\"code-prototype\"")); - - let graph = build_game_creation_seed_task_graph("像素厨房弹幕").expect("task graph"); - let plan = plan_game_creation_agent_pass(&graph, 2, &findings); - assert_eq!(plan.mode, "repair"); - assert!(plan.active_task_ids.contains(&"code-prototype".to_string())); - assert!(plan - .active_task_ids - .contains(&"publish-package".to_string())); - assert_eq!( - plan.repair_routes[0].reason, - "code-runtime+dependency-impact" - ); - } - - #[test] - fn upload_local_asset_writes_file_and_manifest_entry() { - let root = unique_project_path(); - let result = upload_local_asset_at(&root, "../角色.png", "image/png", b"fake-png") - .expect("asset upload"); - - assert_eq!(fs::read(&result.absolute_path).unwrap(), b"fake-png"); - assert!(result.local_path.starts_with("assets/uploads/upload-")); - assert!(result.local_path.ends_with("_角色.png")); - - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["assets"][0]["id"], result.id); - assert_eq!(manifest["assets"][0]["mediaType"], "image/png"); - assert_eq!(manifest["assets"][0]["source"]["kind"], "uploaded"); - assert_eq!(manifest["assets"][0]["localPath"], result.local_path); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - assert!(agent_db.lines().any(|line| { - let record: Value = serde_json::from_str(line).expect("agent db record"); - record["recordType"] == "asset.register" - && record["assetId"] == result.id - && record["localPath"] == result.local_path - && record["source"]["kind"] == "uploaded" - })); - - upload_local_asset_at(&root, "sound.wav", "audio/wav", b"fake-wav").expect("asset upload"); - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["assets"].as_array().unwrap().len(), 2); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_asset_prompt_context_summarizes_uploaded_and_canvas_assets() { - let root = unique_project_path(); - let uploaded = upload_local_asset_at(&root, "../角色.png", "image/png", b"fake-png") - .expect("asset upload"); - write_local_project_file_at(&root, "assets/canvas-hero.png", "fake-image") - .expect("canvas asset file"); - import_canvas_asset_at( - &root, - "assets/canvas-hero.png", - "character", - "image/png", - "canvas-project-1", - Some("resource-1".to_string()), - Some("asset-object-1".to_string()), - Some("task-1".to_string()), - None, - Some("gpt-image-2".to_string()), - ) - .expect("canvas asset import"); - - let context = render_local_asset_prompt_context(&root).expect("asset context"); - assert!(context.contains("# 本地项目资产")); - assert!(context.contains(&uploaded.local_path)); - assert!(context.contains("source=uploaded")); - assert!(context.contains("assets/canvas-hero.png")); - assert!(context.contains("source=canvas")); - assert!(context.contains("canvasProjectId=canvas-project-1")); - assert!(context.contains("resourceId=resource-1")); - assert!(context.contains("assetObjectId=asset-object-1")); - - let prompt_context = append_prompt_context(&context, &"长期记忆\n".repeat(400)); - let truncated_context = truncate_prompt_context(&prompt_context); - assert!(truncated_context.contains("# 本地项目资产")); - assert!(truncated_context.contains(&uploaded.local_path)); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn prompt_context_redacts_secrets_before_truncating() { - let context = [ - "memory/blackboard.md: keep this", - "Authorization: Bearer secret-token", - "Cookie: session=secret", - "game-creator.config.json has apiKey", - "token sk-unit-secret assets/hero.png", - "tnr_sk_unit_secret", - ] - .join("\n"); - - let sanitized = truncate_prompt_context(&context); - - assert!(sanitized.contains("memory/blackboard.md")); - assert!(sanitized.contains("assets/hero.png")); - assert!(sanitized.contains("[redacted sensitive context]")); - assert!(!sanitized.contains("secret-token")); - assert!(!sanitized.contains("session=secret")); - assert!(!sanitized.contains("sk-unit-secret")); - assert!(!sanitized.contains("tnr_sk_unit_secret")); - assert!(!sanitized.contains("game-creator.config.json")); - } - - #[test] - fn upload_local_asset_rejects_empty_file() { - let root = unique_project_path(); - let error = - upload_local_asset_at(&root, "empty.txt", "text/plain", b"").expect_err("empty file"); - - assert!(error.contains("不能为空")); - } - - #[test] - fn register_local_asset_records_existing_asset_with_canvas_source() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_local_project_file_at(&root, "assets/hero.png", "fake-image").expect("asset file"); - - let result = register_local_asset_at( - &root, - "assets/hero.png", - "character", - "image/png", - "canvas", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some("canvas-project-1".to_string()), - resource_id: Some("resource-1".to_string()), - asset_object_id: Some("asset-object-1".to_string()), - task_id: Some("task-1".to_string()), - prompt: Some("像素主角".to_string()), - model: Some("image-model".to_string()), - }, - ) - .expect("asset register"); - - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["assets"][0]["id"], result.id); - assert_eq!(manifest["assets"][0]["kind"], "character"); - assert_eq!(manifest["assets"][0]["localPath"], "assets/hero.png"); - assert_eq!(manifest["assets"][0]["source"]["kind"], "canvas"); - assert_eq!( - manifest["assets"][0]["source"]["canvasProjectId"], - "canvas-project-1" - ); - - let updated = register_local_asset_at( - &root, - "assets/hero.png", - "ui", - "image/png", - "generated", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Generated, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - }, - ) - .expect("asset update"); - assert_eq!(updated.id, result.id); - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["assets"].as_array().unwrap().len(), 1); - assert_eq!(manifest["assets"][0]["kind"], "ui"); - assert_eq!(manifest["assets"][0]["source"]["kind"], "generated"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn register_local_asset_rejects_missing_or_unsafe_path() { - let root = unique_project_path(); - let source = GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Generated, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - }; - - assert!(register_local_asset_at( - &root, - "../outside.png", - "asset", - "image/png", - "generated", - source.clone() - ) - .is_err()); - assert!(register_local_asset_at( - &root, - "assets/missing.png", - "asset", - "image/png", - "generated", - source - ) - .is_err()); - } - - #[test] - fn import_canvas_asset_registers_canvas_source_metadata() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_local_project_file_at(&root, "assets/canvas-hero.png", "fake-image") - .expect("canvas asset file"); - - let result = import_canvas_asset_at( - &root, - "assets/canvas-hero.png", - "character", - "image/png", - "canvas-project-1", - Some("resource-1".to_string()), - Some("asset-object-1".to_string()), - Some("task-1".to_string()), - Some("像素主角".to_string()), - Some("image-model".to_string()), - ) - .expect("canvas import"); - - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["assets"][0]["id"], result.id); - assert_eq!(manifest["assets"][0]["source"]["kind"], "canvas"); - assert_eq!( - manifest["assets"][0]["source"]["canvasProjectId"], - "canvas-project-1" - ); - assert_eq!(manifest["assets"][0]["source"]["resourceId"], "resource-1"); - assert_eq!( - manifest["assets"][0]["source"]["assetObjectId"], - "asset-object-1" - ); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - assert!(agent_db.lines().any(|line| { - let record: Value = serde_json::from_str(line).expect("agent db record"); - record["recordType"] == "asset.register" - && record["assetId"] == result.id - && record["source"]["kind"] == "canvas" - && record["source"]["canvasProjectId"] == "canvas-project-1" - })); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn import_canvas_asset_requires_traceable_canvas_ids() { - let root = unique_project_path(); - write_local_project_file_at(&root, "assets/canvas-hero.png", "fake-image") - .expect("canvas asset file"); - - let missing_project = import_canvas_asset_at( - &root, - "assets/canvas-hero.png", - "character", - "image/png", - "", - Some("resource-1".to_string()), - None, - None, - None, - None, - ) - .expect_err("missing canvas project should fail"); - assert!(missing_project.contains("画板项目")); - - let missing_asset = import_canvas_asset_at( - &root, - "assets/canvas-hero.png", - "character", - "image/png", - "canvas-project-1", - None, - None, - None, - None, - None, - ) - .expect_err("missing canvas asset ids should fail"); - assert!(missing_asset.contains("至少需要一个")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn import_canvas_export_zip_copies_files_and_registers_assets() { - let root = unique_project_path(); - let zip_path = root.with_extension("zip"); - write_test_canvas_export_zip(&zip_path); - - let result = import_canvas_export_at(&root, &zip_path, "canvas-project-1") - .expect("canvas export import"); - - assert_eq!(result.imported_count, 2); - assert!(result.import_root.starts_with("assets/canvas-imports/")); - assert!(root.join(&result.metadata_path).is_file()); - assert!(root - .join(&result.import_root) - .join("images/001-月光主角.png") - .is_file()); - assert!(root - .join(&result.import_root) - .join("media/002-玻璃月光.mp3") - .is_file()); - assert!(result - .assets - .iter() - .any(|asset| asset.local_path.ends_with("images/001-月光主角.png"))); - assert!(result - .assets - .iter() - .any(|asset| asset.local_path.ends_with("media/002-玻璃月光.mp3"))); - - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - let assets = manifest["assets"].as_array().unwrap(); - assert_eq!(assets.len(), 2); - assert!(assets.iter().any(|asset| { - asset["kind"] == "character" - && asset["mediaType"] == "image/png" - && asset["source"]["kind"] == "canvas" - && asset["source"]["canvasProjectId"] == "canvas-project-1" - && asset["source"]["assetObjectId"] == "asset-object-1" - && asset["source"]["taskId"] == "42" - && asset["source"]["model"] == "gpt-image-2" - })); - assert!(assets.iter().any(|asset| { - asset["kind"] == "audio" - && asset["mediaType"] == "audio/mpeg" - && asset["source"]["assetObjectId"] - .as_str() - .is_some_and(|value| value.starts_with("canvas-export:media/")) - })); - - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - let records = agent_db - .lines() - .map(|line| serde_json::from_str::(line).expect("agent db record")) - .collect::>(); - assert!(records.iter().any(|record| { - record["recordType"] == "canvas.export_import" - && record["canvasProjectId"] == "canvas-project-1" - && record["projectTitle"] == "月光画布" - && record["importedCount"] == 2 - })); - assert!(!agent_db.contains(zip_path.to_string_lossy().as_ref())); - - fs::remove_file(zip_path).ok(); - fs::remove_dir_all(root).ok(); - } - - #[tokio::test] - async fn sync_canvas_project_assets_downloads_external_resources() { - let root = unique_project_path(); - let base_url = spawn_mock_external_canvas_api_server(); - - let result = sync_canvas_project_assets_at( - &root, - "canvas-project-1", - Some(base_url), - Some("test-editor-api-key".to_string()), - ) - .await - .expect("canvas project sync"); - - assert_eq!(result.canvas_project_id, "canvas-project-1"); - assert_eq!(result.imported_count, 1); - assert!(result.import_root.starts_with("assets/canvas-sync/")); - assert_eq!( - fs::read(&result.assets[0].absolute_path).unwrap(), - b"fake-png" - ); - - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["assets"][0]["kind"], "character"); - assert_eq!(manifest["assets"][0]["mediaType"], "image/png"); - assert_eq!(manifest["assets"][0]["source"]["kind"], "canvas"); - assert_eq!( - manifest["assets"][0]["source"]["canvasProjectId"], - "canvas-project-1" - ); - assert_eq!(manifest["assets"][0]["source"]["resourceId"], "resource-1"); - assert_eq!( - manifest["assets"][0]["source"]["assetObjectId"], - "asset-object-1" - ); - assert_eq!(manifest["assets"][0]["source"]["model"], "gpt-image-2"); - - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - assert!(agent_db.contains("\"recordType\":\"canvas.project_sync\"")); - assert!(!agent_db.contains("test-editor-api-key")); - - fs::remove_dir_all(root).ok(); - } - - #[tokio::test] - async fn generate_platform_art_asset_downloads_and_registers_external_image() { - let root = unique_project_path(); - let config_dir = unique_project_path(); - let base_url = spawn_mock_external_canvas_api_server(); - fs::create_dir_all(&config_dir).expect("runtime config dir"); - fs::write( - config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), - serde_json::json!({ - "editorApi": { - "baseUrl": base_url, - "apiKey": "editor-unit-key" - } - }) - .to_string(), - ) - .expect("write runtime config"); - let _guard = use_test_runtime_config_dir(config_dir.clone()); - let art_role = AgentRoleBrief { - group_definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2], - role_definition: ART_AGENT_ROLES[1], - markdown: "需要像素月光主角和厨房场景素材。".to_string(), - relative_path: ".agent/passes/pass-1/groups/art/asset.md".to_string(), - memory_relative_path: agent_role_memory_relative_path( - GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2], - ART_AGENT_ROLES[1], - ), - status: "completed".to_string(), - tool_id: "agent.role.brief.art.asset".to_string(), - summary: "规划首版美术资产".to_string(), - }; - let briefs = vec![AgentGroupBrief { - definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2], - markdown: "美术组汇总。".to_string(), - relative_path: ".agent/passes/pass-1/groups/art.md".to_string(), - role_briefs: vec![art_role], - }]; - - let result = generate_platform_art_asset_at(&root, "月光弹幕厨房", &briefs) - .await - .expect("platform art generation"); - - assert!(result - .asset - .local_path - .starts_with("assets/canvas-generated/")); - assert_eq!(fs::read(&result.asset.absolute_path).unwrap(), b"fake-png"); - assert_eq!(result.resource_id.as_deref(), Some("resource-1")); - assert_eq!(result.asset_object_id.as_deref(), Some("asset-object-1")); - assert_eq!(result.task_id.as_deref(), Some("task-1")); - assert_eq!(result.model.as_deref(), Some("gpt-image-2")); - - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - let asset = &manifest["assets"][0]; - assert_eq!(asset["kind"], "character"); - assert_eq!(asset["mediaType"], "image/png"); - assert_eq!(asset["source"]["kind"], "canvas"); - assert_eq!(asset["source"]["canvasProjectId"], "canvas-project-1"); - assert_eq!(asset["source"]["resourceId"], "resource-1"); - assert_eq!(asset["source"]["assetObjectId"], "asset-object-1"); - assert_eq!(asset["source"]["taskId"], "task-1"); - assert_eq!(asset["source"]["model"], "gpt-image-2"); - - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - assert!(agent_db.contains("\"recordType\":\"canvas.asset_generate\"")); - assert!(!agent_db.contains("editor-unit-key")); - - fs::remove_dir_all(root).ok(); - fs::remove_dir_all(config_dir).ok(); - } - - #[tokio::test] - async fn platform_art_generation_step_falls_back_without_leaking_editor_key() { - let root = unique_project_path(); - let config_dir = unique_project_path(); - let base_url = spawn_mock_external_canvas_generation_failure_server(); - fs::create_dir_all(&config_dir).expect("runtime config dir"); - fs::write( - config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), - serde_json::json!({ - "editorApi": { - "baseUrl": base_url, - "apiKey": "editor-fallback-secret" - } - }) - .to_string(), - ) - .expect("write runtime config"); - let _guard = use_test_runtime_config_dir(config_dir.clone()); - let art_role = AgentRoleBrief { - group_definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2], - role_definition: ART_AGENT_ROLES[1], - markdown: "需要像素月光主角和厨房场景素材。".to_string(), - relative_path: ".agent/passes/pass-1/groups/art/asset.md".to_string(), - memory_relative_path: agent_role_memory_relative_path( - GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2], - ART_AGENT_ROLES[1], - ), - status: "completed".to_string(), - tool_id: "agent.role.brief.art.asset".to_string(), - summary: "规划首版美术资产".to_string(), - }; - let briefs = vec![AgentGroupBrief { - definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2], - markdown: "美术组汇总。".to_string(), - relative_path: ".agent/passes/pass-1/groups/art.md".to_string(), - role_briefs: vec![art_role], - }]; - - let step = maybe_generate_platform_art_asset_step(&root, "月光弹幕厨房", &briefs, 1, None) - .await - .expect("platform art generation step"); - - assert_eq!(step.status, "failed"); - assert!(step.output_paths.is_empty()); - assert!(step.summary.contains("HTTP 500")); - assert!(!step.summary.contains("editor-fallback-secret")); - assert!(read_manifest_for_project(&root).unwrap().assets.is_empty()); - - fs::remove_dir_all(root).ok(); - fs::remove_dir_all(config_dir).ok(); - } - - #[tokio::test] - async fn generate_platform_art_asset_respects_project_policy() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["canvas.asset_generate".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - - let error = generate_platform_art_asset_at(&root, "月光弹幕厨房", &[]) - .await - .expect_err("policy should deny platform art generation"); - - assert!(error.contains("项目权限策略拒绝执行:canvas.asset_generate")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn canvas_project_url_defaults_to_local_editor_route() { - let url = build_canvas_project_url(None, Some("canvas-project-1")).expect("canvas url"); - - assert_eq!( - url, - "http://127.0.0.1:3000/editor/canvas?projectid=canvas-project-1" - ); - } - - #[test] - fn canvas_project_url_normalizes_localhost_base() { - let url = build_canvas_project_url( - Some("http://localhost:3100/old/path?ignored=1#fragment"), - Some("项目 1"), - ) - .expect("canvas url"); - - assert_eq!( - url, - "http://localhost:3100/editor/canvas?projectid=%E9%A1%B9%E7%9B%AE+1" - ); - } - - #[test] - fn canvas_project_url_rejects_non_local_editor_base() { - assert!(build_canvas_project_url(Some("https://example.com"), Some("p")).is_err()); - assert!(build_canvas_project_url(Some("file:///tmp/editor"), Some("p")).is_err()); - assert!(build_canvas_project_url(Some("http://192.168.1.5:3000"), Some("p")).is_err()); - } - - #[test] - fn local_game_memory_can_read_write_and_delete_long_memory() { - let root = unique_project_path(); - - let missing = read_local_game_memory_at(&root, "long").expect("read missing memory"); - assert_eq!(missing.scope, "long"); - assert!(!missing.exists); - - let written = - write_local_game_memory_at(&root, "long", "# 项目长期记忆\n").expect("write memory"); - assert!(written.exists); - assert_eq!(written.content, "# 项目长期记忆\n"); - - let read = read_local_game_memory_at(&root, "project").expect("read memory"); - assert_eq!(read.scope, "long"); - assert_eq!(read.content, "# 项目长期记忆\n"); - - let deleted = delete_local_game_memory_at(&root, "long").expect("delete memory"); - assert!(!deleted.exists); - assert!(!root.join("memory/project.md").exists()); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_game_memory_can_read_write_and_delete_blackboard_memory() { - let root = unique_project_path(); - - let written = - write_local_game_memory_at(&root, "blackboard", "# 项目黑板\n").expect("write memory"); - assert_eq!(written.scope, "blackboard"); - assert_eq!(written.content, "# 项目黑板\n"); - - let read = read_local_game_memory_at(&root, "blackboard").expect("read memory"); - assert_eq!(read.scope, "blackboard"); - assert_eq!(read.content, "# 项目黑板\n"); - - let deleted = delete_local_game_memory_at(&root, "blackboard").expect("delete memory"); - assert!(!deleted.exists); - assert!(!root.join(PROJECT_BLACKBOARD_MEMORY_PATH).exists()); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_agent_memory_reads_private_memory_by_task_id() { - let root = unique_project_path(); - let memory_path = root.join("memory/agents/design/director.md"); - fs::create_dir_all(memory_path.parent().unwrap()).expect("agent memory dir"); - fs::write(&memory_path, "# 策划 Director 私有记忆\n").expect("agent memory"); - - let read = read_local_agent_memory_at(&root, "design-director").expect("read agent memory"); - assert_eq!(read.task_id, "design-director"); - assert!(read.path.ends_with("memory/agents/design/director.md")); - assert_eq!(read.content, "# 策划 Director 私有记忆\n"); - assert!(read.exists); - - let missing = - read_local_agent_memory_at(&root, "art-asset-plan").expect("read missing memory"); - assert_eq!(missing.task_id, "art-asset-plan"); - assert!(missing.path.ends_with("memory/agents/art/asset.md")); - assert!(!missing.exists); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_agent_memory_writes_private_memory_by_task_id() { - let root = unique_project_path(); - - let written = write_local_agent_memory_at( - &root, - "design-director", - "# 策划 Director 私有记忆\n- 保留轻量像素风\n", - ) - .expect("write agent memory"); - assert_eq!(written.task_id, "design-director"); - assert!(written.path.ends_with("memory/agents/design/director.md")); - assert!(written.exists); - assert_eq!( - written.content, - "# 策划 Director 私有记忆\n- 保留轻量像素风\n" - ); - - let read = read_local_agent_memory_at(&root, "design-director").expect("read agent memory"); - assert_eq!(read.content, "# 策划 Director 私有记忆\n- 保留轻量像素风\n"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_memory_reads_respect_project_policy() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_local_game_memory_at(&root, "long", "# 项目长期记忆\n").expect("write memory"); - let agent_memory_path = root.join("memory/agents/design/director.md"); - fs::create_dir_all(agent_memory_path.parent().unwrap()).expect("agent memory dir"); - fs::write(&agent_memory_path, "# Agent 私有记忆\n").expect("agent memory"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["memory.read".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - let project_path = root.to_string_lossy().into_owned(); - - let game_error = read_local_game_memory(project_path.clone(), "long".to_string()) - .expect_err("memory.read denied"); - let agent_error = read_local_agent_memory(project_path, "design-director".to_string()) - .expect_err("agent memory read denied"); - - assert!(game_error.contains("项目权限策略拒绝执行:memory.read")); - assert!(agent_error.contains("项目权限策略拒绝执行:memory.read")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_agent_memory_writes_respect_project_policy() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["memory.write".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - - let error = write_local_agent_memory( - root.to_string_lossy().into_owned(), - "design-director".to_string(), - "denied memory".to_string(), - ) - .expect_err("agent memory write denied"); - - assert!(error.contains("项目权限策略拒绝执行:memory.write")); - assert!(!root.join("memory/agents/design/director.md").exists()); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_game_memory_rejects_unknown_scope() { - let root = unique_project_path(); - let error = - read_local_game_memory_at(&root, "notes").expect_err("unknown memory scope fails"); - - assert!(error.contains("short、long 或 blackboard")); - } - - #[cfg(unix)] - #[test] - fn local_game_memory_rejects_symlinked_memory_dir() { - use std::os::unix::fs::symlink; - - let root = unique_project_path(); - let outside = unique_project_path(); - fs::create_dir_all(&root).expect("project dir"); - fs::create_dir_all(&outside).expect("outside dir"); - symlink(&outside, root.join("memory")).expect("memory symlink"); - - assert!(write_local_game_memory_at(&root, "long", "secret").is_err()); - assert!(!outside.join("project.md").exists()); - - fs::remove_dir_all(root).ok(); - fs::remove_dir_all(outside).ok(); - } - - #[test] - fn local_conversation_can_read_and_append_project_and_agent_messages() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - let project = append_local_conversation_message_at( - &root, - None, - LocalConversationMessage { - role: "user".to_string(), - content: "做一个像素动作游戏".to_string(), - agent_id: None, - }, - ) - .expect("append project conversation"); - assert!(project.path.ends_with(".agent/conversations/project.jsonl")); - assert_eq!(project.agent_id, None); - assert_eq!(project.messages[0].content, "做一个像素动作游戏"); - - let agent = append_local_conversation_message_at( - &root, - Some("design-director"), - LocalConversationMessage { - role: "user".to_string(), - content: "策划 agent 备注".to_string(), - agent_id: None, - }, - ) - .expect("append agent conversation"); - assert!(agent - .path - .ends_with(".agent/conversations/agents/design-director.jsonl")); - assert_eq!(agent.agent_id.as_deref(), Some("design-director")); - assert_eq!( - agent.messages[0].agent_id.as_deref(), - Some("design-director") - ); - - let read_agent = - read_local_conversation_at(&root, Some("design-director")).expect("read agent"); - assert_eq!(read_agent.messages.len(), 1); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - assert!(agent_db.contains("\"recordType\":\"conversation.message\"")); - assert!(agent_db.contains("\"agentId\":\"design-director\"")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_conversation_prompt_context_scopes_agent_messages() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - append_local_conversation_message_at( - &root, - None, - LocalConversationMessage { - role: "user".to_string(), - content: "希望主角用月光厨房做弹幕躲避".to_string(), - agent_id: None, - }, - ) - .expect("append project conversation"); - append_local_conversation_message_at( - &root, - Some("art-asset-plan"), - LocalConversationMessage { - role: "assistant".to_string(), - content: "Authorization: Bearer secret-token\n美术建议:霓虹锅铲和月亮灶台" - .to_string(), - agent_id: None, - }, - ) - .expect("append agent conversation"); - append_local_conversation_message_at( - &root, - Some("design-director"), - LocalConversationMessage { - role: "user".to_string(), - content: "策划建议:只保留三种输入".to_string(), - agent_id: None, - }, - ) - .expect("append other agent conversation"); - - let project_context = - render_local_conversation_prompt_context(&root, None).expect("project context"); - - assert!(project_context.contains("# 最近对话上下文")); - assert!(project_context.contains("[project / user] 希望主角用月光厨房做弹幕躲避")); - assert!(!project_context.contains("[art-asset-plan / assistant]")); - assert!(!project_context.contains("美术建议:霓虹锅铲和月亮灶台")); - - let art_context = render_local_conversation_prompt_context(&root, Some("art-asset-plan")) - .expect("art agent context"); - assert!(art_context.contains("[project / user] 希望主角用月光厨房做弹幕躲避")); - assert!(art_context.contains("[art-asset-plan / assistant]")); - assert!(art_context.contains("美术建议:霓虹锅铲和月亮灶台")); - assert!(art_context.contains("[redacted sensitive context]")); - assert!(!art_context.contains("secret-token")); - assert!(!art_context.contains("策划建议:只保留三种输入")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_agent_role_brief_keeps_project_and_agent_conversation_context() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - append_local_conversation_message_at( - &root, - None, - LocalConversationMessage { - role: "user".to_string(), - content: "项目对话:主角必须挥舞月光锅铲".to_string(), - agent_id: None, - }, - ) - .expect("append project conversation"); - append_local_conversation_message_at( - &root, - Some("art-asset-plan"), - LocalConversationMessage { - role: "assistant".to_string(), - content: "Authorization: Bearer sk-brief-secret\nAgent 对话:美术要用蓝紫霓虹厨房" - .to_string(), - agent_id: None, - }, - ) - .expect("append agent conversation"); - - let conversation_context = - render_local_conversation_prompt_context(&root, Some("art-asset-plan")) - .expect("conversation context"); - let markdown = render_local_agent_role_brief( - GAME_CREATOR_AGENT_GROUP_DEFINITIONS - .iter() - .find(|definition| definition.id == "art") - .copied() - .expect("art group"), - ART_AGENT_ROLES - .iter() - .find(|role| role.task_id == "art-asset-plan") - .copied() - .expect("asset role"), - "做一个月光厨房弹幕游戏", - &conversation_context, - "", - "", - "", - "", - "", - "", - "", - "", - 1, - ); - - assert!(markdown.contains("项目对话:主角必须挥舞月光锅铲")); - assert!(markdown.contains("Agent 对话:美术要用蓝紫霓虹厨房")); - assert!(markdown.contains("[redacted sensitive context]")); - assert!(!markdown.contains("sk-brief-secret")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_conversation_prompt_context_can_include_all_agents() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - append_local_conversation_message_at( - &root, - None, - LocalConversationMessage { - role: "user".to_string(), - content: "项目对话:月光锅铲".to_string(), - agent_id: None, - }, - ) - .expect("append project conversation"); - append_local_conversation_message_at( - &root, - Some("art-asset-plan"), - LocalConversationMessage { - role: "assistant".to_string(), - content: "美术对话:蓝紫霓虹厨房".to_string(), - agent_id: None, - }, - ) - .expect("append art conversation"); - append_local_conversation_message_at( - &root, - Some("code-prototype"), - LocalConversationMessage { - role: "assistant".to_string(), - content: "程序对话:保留反弹碰撞".to_string(), - agent_id: None, - }, - ) - .expect("append code conversation"); - - let context = - render_local_conversation_prompt_context(&root, Some("*")).expect("all agent context"); - - assert!(context.contains("[project / user] 项目对话:月光锅铲")); - assert!(context.contains("[art-asset-plan / assistant] 美术对话:蓝紫霓虹厨房")); - assert!(context.contains("[code-prototype / assistant] 程序对话:保留反弹碰撞")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_conversation_rejects_unsafe_agent_id() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - let error = - read_local_conversation_at(&root, Some("../design")).expect_err("unsafe agent id"); - assert!(error.contains("agent id")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_conversation_agent_id_comes_from_outer_target() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - let project = append_local_conversation_message_at( - &root, - None, - LocalConversationMessage { - role: "user".to_string(), - content: "项目聊天不能伪造 agent".to_string(), - agent_id: Some("design-director".to_string()), - }, - ) - .expect("append project conversation"); - assert_eq!(project.messages[0].agent_id, None); - - let agent = append_local_conversation_message_at( - &root, - Some("art-asset-plan"), - LocalConversationMessage { - role: "assistant".to_string(), - content: "单 agent 对话使用外层目标".to_string(), - agent_id: Some("design-director".to_string()), - }, - ) - .expect("append agent conversation"); - assert_eq!( - agent.messages[0].agent_id.as_deref(), - Some("art-asset-plan") - ); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_conversation_write_respects_project_policy() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["conversation.write".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - - let error = append_local_conversation_message( - root.to_string_lossy().into_owned(), - None, - LocalConversationMessage { - role: "user".to_string(), - content: "should fail".to_string(), - agent_id: None, - }, - ) - .expect_err("conversation write denied"); - assert!(error.contains("项目权限策略拒绝执行:conversation.write")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_conversation_read_respects_project_policy() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - append_local_conversation_message_at( - &root, - None, - LocalConversationMessage { - role: "user".to_string(), - content: "hello".to_string(), - agent_id: None, - }, - ) - .expect("append conversation"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["conversation.read".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - - let error = read_local_conversation(root.to_string_lossy().into_owned(), None) - .expect_err("conversation read denied"); - assert!(error.contains("项目权限策略拒绝执行:conversation.read")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn agent_run_history_prunes_to_latest_hundred_traces() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - for index in 0..101 { - let run_id = format!("run-{index:03}"); - let trace = GameCreationAgentRunTrace { - schema_version: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION.to_string(), - run_id: run_id.clone(), - command_id: "game.generate_draft".to_string(), - status: "passed".to_string(), - lifecycle_status: Some("done".to_string()), - passes: 1, - max_passes: GAME_CREATOR_AGENT_LOOP_MAX_PASSES, - tool_call_count: 0, - max_tool_calls: GAME_CREATOR_AGENT_TOOL_CALL_MAX, - stop_reason: "evaluator-passed".to_string(), - goal: "像素动作原型".to_string(), - coordination: "filesystem".to_string(), - steps: Vec::new(), - artifacts: Vec::new(), - task_graph: GameCreationAgentRunTaskGraphTrace { - goal: "像素动作原型".to_string(), - ready_task_ids: Vec::new(), - active_task_ids: Vec::new(), - carried_task_ids: Vec::new(), - repair_focus: Vec::new(), - repair_routes: Vec::new(), - tasks: Vec::new(), - }, - pass_plans: Vec::new(), - next_step: "preview-playtest".to_string(), - error: None, - updated_at: index, - }; - write_agent_run_trace_payload(&root, &trace).expect("write run trace"); - assert!(root.join(format!(".agent/runs/{run_id}.json")).exists()); - } - - let run_dir = root.join(".agent/runs"); - let run_count = fs::read_dir(&run_dir) - .expect("run dir") - .filter_map(Result::ok) - .filter(|entry| { - entry.path().extension().and_then(|value| value.to_str()) == Some("json") - }) - .count(); - assert_eq!(run_count, GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT); - assert!(!root.join(".agent/runs/run-000.json").exists()); - assert!(root.join(".agent/runs/run-001.json").exists()); - assert!(root.join(".agent/runs/run-100.json").exists()); - let latest: GameCreationAgentRunTrace = - serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) - .expect("latest run trace"); - assert_eq!(latest.run_id, "run-100"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_project_file_commands_read_write_list_and_delete_text_files() { - let root = unique_project_path(); - - let written = write_local_project_file_at(&root, "game/notes.txt", "hello") - .expect("write project file"); - assert_eq!(written.path, "game/notes.txt"); - assert!(!written.deleted); - - let read = read_local_project_file_at(&root, "game/notes.txt").expect("read project file"); - assert_eq!(read.content, "hello"); - - let listed = list_local_project_files_at(&root).expect("list project files"); - assert!(listed - .files - .iter() - .any(|file| file.path == "game/notes.txt" && file.kind == "file")); - assert!(listed - .files - .iter() - .any(|file| file.path == "game/notes.txt" && file.modified_at > 0)); - - let deleted = - delete_local_project_file_at(&root, "game/notes.txt").expect("delete project file"); - assert!(deleted.deleted); - assert!(!root.join("game/notes.txt").exists()); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_project_checkpoint_diff_restore_and_index_are_recorded() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_local_project_file_at(&root, "game/notes.txt", "v1").expect("write notes"); - write_local_project_file_at(&root, "exports/README.md", "publish") - .expect("write export readme"); - let checkpoint = - create_local_project_checkpoint_at(&root).expect("create project checkpoint"); - - write_local_project_file_at(&root, "game/notes.txt", "v2").expect("change notes"); - write_local_project_file_at(&root, "game/extra.txt", "new").expect("add file"); - delete_local_project_file_at(&root, "exports/README.md").expect("delete tracked file"); - let diff = - diff_local_project_checkpoint_at(&root, &checkpoint.checkpoint_id).expect("diff"); - - assert!(diff - .changed - .iter() - .any(|entry| entry.path == "game/notes.txt")); - assert!(diff - .added - .iter() - .any(|entry| entry.path == "game/extra.txt")); - assert!(diff - .deleted - .iter() - .any(|entry| entry.path == "exports/README.md")); - - let index = build_local_project_index_at(&root).expect("project index"); - assert!(index.file_count > 0); - assert!(root.join(PROJECT_INDEX_PATH).exists()); - - let restored = - restore_local_project_checkpoint_at(&root, &checkpoint.checkpoint_id).expect("restore"); - assert!(restored.restored_count > 0); - assert_eq!( - fs::read_to_string(root.join("game/notes.txt")).expect("restored notes"), - "v1" - ); - assert!(root.join("exports/README.md").exists()); - assert!(!root.join("game/extra.txt").exists()); - assert_eq!(restored.deleted_count, 1); - - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - assert!(agent_db.contains("\"recordType\":\"project.checkpoint\"")); - assert!(agent_db.contains("\"recordType\":\"project.index\"")); - assert!(agent_db.contains("\"recordType\":\"project.restore\"")); - assert!(agent_db.contains("\"deletedCount\":1")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_project_export_package_uses_runtime_whitelist_and_records() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) - .expect("write playable html"); - write_local_project_file_at(&root, "assets/hero.txt", "hero asset").expect("write asset"); - write_local_project_file_at(&root, "exports/README.md", "playtest notes") - .expect("write readme"); - write_local_project_file_at(&root, "memory/project.md", "private memory") - .expect("write memory"); - fs::write(root.join(".env"), "LOCAL_PLACEHOLDER=not-for-export") - .expect("write local config"); - fs::write(root.join(".agent/run.latest.json"), "{}").expect("write trace"); - - let result = export_local_project_package_at(&root).expect("export package"); - - assert_eq!(result.file_count, 3); - assert!(result - .package_relative_path - .starts_with("exports/playtest-package-")); - assert!(result.package_relative_path.ends_with(".zip")); - let file = File::open(&result.package_path).expect("open export package"); - let mut archive = zip::ZipArchive::new(file).expect("read export package"); - let mut names = Vec::new(); - for index in 0..archive.len() { - names.push( - archive - .by_index(index) - .expect("zip entry") - .name() - .to_string(), - ); - } - names.sort(); - assert_eq!( - names, - vec![ - "assets/hero.txt".to_string(), - "exports/README.md".to_string(), - "game/index.html".to_string(), - ] - ); - assert!(!names.iter().any(|name| name.starts_with(".agent/"))); - assert!(!names.iter().any(|name| name.starts_with("memory/"))); - assert!(!names.iter().any(|name| name.contains(".env"))); - - let manifest = serde_json::from_str::( - &fs::read_to_string(root.join(".agent/manifest.json")).expect("manifest"), - ) - .expect("manifest json"); - assert!(manifest["commandRuns"] - .as_array() - .expect("command runs") - .iter() - .any(|run| run["commandId"] == "project.export_package")); - let command_log = - fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); - assert!(command_log.contains("project.export_package")); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - assert!(agent_db.contains("\"recordType\":\"project.export_package\"")); - assert!(!agent_db.contains("LOCAL_PLACEHOLDER=not-for-export")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_project_export_package_list_only_returns_recent_playtest_zips() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join("exports/playtest-package-001.zip"), "old").expect("write old zip"); - fs::write(root.join("exports/not-playtest.zip"), "ignore").expect("write ignored zip"); - fs::write(root.join("exports/playtest-package-002.zip"), "newer").expect("write newer zip"); - fs::write(root.join("game/playtest-package-003.zip"), "wrong dir") - .expect("write wrong dir zip"); - - let result = list_local_project_export_packages_at(&root).expect("list packages"); - - assert_eq!(result.project_path, root.to_string_lossy().into_owned()); - let paths = result - .packages - .iter() - .map(|package| package.package_relative_path.as_str()) - .collect::>(); - assert_eq!( - paths, - vec![ - "exports/playtest-package-002.zip", - "exports/playtest-package-001.zip" - ] - ); - assert_eq!(result.packages[0].total_bytes, 5); - assert!(result.packages[0].modified_at > 0); - - fs::remove_dir_all(root).ok(); - } - - #[cfg(unix)] - #[test] - fn local_project_export_package_list_skips_symlink_packages() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join("exports/playtest-package-real.zip"), "real").expect("write zip"); - std::os::unix::fs::symlink( - root.join("memory/project.md"), - root.join("exports/playtest-package-link.zip"), - ) - .expect("create package symlink"); - - let result = list_local_project_export_packages_at(&root).expect("list packages"); - - assert_eq!(result.packages.len(), 1); - assert_eq!( - result.packages[0].package_relative_path, - "exports/playtest-package-real.zip" - ); - fs::remove_dir_all(root).ok(); - } - - #[cfg(unix)] - #[test] - fn local_project_export_package_rejects_symlink_assets() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) - .expect("write playable html"); - write_local_project_file_at(&root, "exports/README.md", "playtest notes") - .expect("write readme"); - std::os::unix::fs::symlink( - root.join("memory/project.md"), - root.join("assets/private.md"), - ) - .expect("create symlink asset"); - - let error = export_local_project_package_at(&root).expect_err("symlink should be rejected"); - - assert!(error.contains("试玩包不能包含符号链接")); - assert!(!root.join("exports").join("playtest-package-").exists()); - fs::remove_dir_all(root).ok(); - } - - #[cfg(unix)] - #[test] - fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) - .expect("write playable html"); - write_local_project_file_at(&root, "memory/project.md", "private memory") - .expect("write memory"); - std::os::unix::fs::symlink( - root.join("memory/project.md"), - root.join("exports/README.md"), - ) - .expect("create readme symlink"); - - let error = export_local_project_package_at(&root).expect_err("readme symlink rejected"); - - assert!(error.contains("符号链接"), "{error}"); - fs::remove_file(root.join("exports/README.md")).expect("remove readme symlink"); - write_local_project_file_at(&root, "exports/README.md", "playtest notes") - .expect("write readme"); - fs::remove_dir_all(root.join("assets")).expect("remove assets dir"); - fs::create_dir_all(root.join("memory/assets")).expect("create memory assets"); - fs::write(root.join("memory/assets/hero.txt"), "private asset").expect("write asset"); - std::os::unix::fs::symlink(root.join("memory/assets"), root.join("assets")) - .expect("create assets symlink"); - - let error = export_local_project_package_at(&root).expect_err("assets symlink rejected"); - - assert!(error.contains("符号链接"), "{error}"); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_project_export_package_requires_playable_html_and_readme() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - let missing_readme = - export_local_project_package_at(&root).expect_err("default html is not playable"); - assert!(missing_readme.contains("游戏入口必须包含可渲染画布")); - - write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) - .expect("write playable html"); - let missing_readme = - export_local_project_package_at(&root).expect_err("readme should be required"); - assert!(missing_readme.contains("exports/README.md")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { - let root = unique_project_path(); - let first = acquire_project_write_lock(&root, "file.write").expect("first lock"); - - let error = - acquire_project_write_lock(&root, "file.delete").expect_err("second lock fails"); - assert!(error.contains("项目正在被其他写操作占用")); - - drop(first); - acquire_project_write_lock(&root, "file.delete").expect("lock released"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn project_permission_policy_can_deny_mutating_commands() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["file.write".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - - let error = - enforce_project_permission_policy(&root, "file.write").expect_err("denied command"); - assert!(error.contains("项目权限策略拒绝执行")); - enforce_project_permission_policy(&root, "file.read").expect("read allowed"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn project_permission_policy_read_respects_project_policy() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["project.policy_read".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - - let error = read_project_permission_policy(root.to_string_lossy().into_owned()) - .expect_err("project.policy_read denied"); - assert!(error.contains("项目权限策略拒绝执行:project.policy_read")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_game_manifest_reads_respect_declared_command_policy() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["project.status".to_string(), "asset.list".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - let project_path = root.to_string_lossy().into_owned(); - - let status_error = get_local_game_manifest(project_path.clone(), None) - .expect_err("default project.status denied"); - let asset_error = - get_local_game_manifest(project_path.clone(), Some("asset.list".to_string())) - .expect_err("asset.list denied"); - let unsupported_error = - get_local_game_manifest(project_path, Some("file.read".to_string())) - .expect_err("unsupported manifest command denied"); - - assert!(status_error.contains("项目权限策略拒绝执行:project.status")); - assert!(asset_error.contains("项目权限策略拒绝执行:asset.list")); - assert!(unsupported_error.contains("不支持通过 manifest 执行命令:file.read")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_project_file_read_and_list_respect_project_policy() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join("game/notes.txt"), "hello").expect("notes"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["file.list".to_string(), "file.read".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - let project_path = root.to_string_lossy().into_owned(); - - let list_error = - list_local_project_files(project_path.clone()).expect_err("file.list denied"); - let read_error = read_local_project_file(project_path, "game/notes.txt".to_string(), None) - .expect_err("file.read denied"); - - assert!(list_error.contains("项目权限策略拒绝执行:file.list")); - assert!(read_error.contains("项目权限策略拒绝执行:file.read")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_project_file_read_can_enforce_agent_trace_read_policy() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join(".agent/run.latest.json"), "{}").expect("trace"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["agent.trace_read".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - let project_path = root.to_string_lossy().into_owned(); - - let trace_error = read_local_project_file( - project_path.clone(), - ".agent/run.latest.json".to_string(), - Some("agent.trace_read".to_string()), - ) - .expect_err("agent.trace_read denied"); - let scope_error = read_local_project_file( - project_path.clone(), - "game/notes.txt".to_string(), - Some("agent.trace_read".to_string()), - ) - .expect_err("trace command cannot read arbitrary files"); - let unsupported_error = read_local_project_file( - project_path, - ".agent/run.latest.json".to_string(), - Some("project.status".to_string()), - ) - .expect_err("unsupported file read command denied"); - - assert!(trace_error.contains("项目权限策略拒绝执行:agent.trace_read")); - assert!(scope_error.contains("agent.trace_read 只能读取 Agent run trace")); - assert!(unsupported_error.contains("不支持通过文件读取执行命令:project.status")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_project_file_commands_reject_unsafe_paths() { - let root = unique_project_path(); - - assert!(read_local_project_file_at(&root, "../secret.txt").is_err()); - assert!(read_local_project_file_at(&root, "/tmp/secret.txt").is_err()); - assert!(write_local_project_file_at(&root, "game\\secret.txt", "x").is_err()); - assert!(write_local_project_file_at(&root, "C:/secret.txt", "x").is_err()); - } - - #[test] - fn local_project_file_read_rejects_sensitive_config_files() { - let root = unique_project_path(); - fs::create_dir_all(root.join("nested")).expect("nested dir"); - fs::write(root.join(".env"), "OPENAI_API_KEY=secret").expect("env file"); - fs::write(root.join("nested/.env.local"), "TOKEN=secret").expect("local env file"); - fs::write( - root.join(GAME_CREATOR_CONFIG_FILE_NAME), - "{\"apiKey\":\"secret\"}", - ) - .expect("config file"); - fs::write( - root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), - "{\"apiKey\":\"secret\"}", - ) - .expect("local config file"); - - for path in [ - ".env", - "nested/.env.local", - GAME_CREATOR_CONFIG_FILE_NAME, - GAME_CREATOR_LOCAL_CONFIG_FILE_NAME, - ] { - let error = read_local_project_file_at(&root, path) - .expect_err("sensitive project file should not be readable"); - assert!(error.contains("敏感配置文件")); - } - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn limited_local_command_runs_static_game_smoke_and_writes_log() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - fs::write( - root.join("game/index.html"), - fake_llm_game_draft().game_html, - ) - .expect("write playable game html"); - - let result = - run_limited_local_command_at(&root, "game.static_smoke").expect("static smoke"); - - assert_eq!(result.command_id, "game.static_smoke"); - assert_eq!(result.status, "completed"); - assert!(result.output.contains("game/index.html")); - let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); - assert!(log.contains("command.run_limited game.static_smoke")); - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["commandRuns"][0]["commandId"], "game.static_smoke"); - assert_eq!(manifest["commandRuns"][0]["status"], "completed"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_permission_log_appends_to_command_log() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - append_local_permission_log_at(&root, "permission.pending", "preview.start") - .expect("pending log"); - append_local_permission_log_at(&root, "permission.confirm", "preview.start") - .expect("confirm log"); - append_local_permission_log_at(&root, "permission.cancel", "memory.write") - .expect("cancel log"); - append_local_permission_log_at(&root, "command.auto", "preview.status").expect("auto log"); - - let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); - assert!(log.contains("permission.pending preview.start")); - assert!(log.contains("permission.confirm preview.start")); - assert!(log.contains("permission.cancel memory.write")); - assert!(log.contains("command.auto preview.status")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_permission_log_rejects_unknown_event_and_command() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - let event_error = - append_local_permission_log_at(&root, "permission.grant", "preview.start") - .expect_err("unknown event should fail"); - let command_error = - append_local_permission_log_at(&root, "permission.pending", "shell.exec") - .expect_err("unknown command should fail"); - let auto_permission_error = - append_local_permission_log_at(&root, "command.auto", "agent.retry") - .expect_err("confirm command should not be auto-logged"); - - assert!(event_error.contains("不支持的命令日志事件")); - assert!(command_error.contains("不支持的内置命令")); - assert!(auto_permission_error.contains("auto 权限命令")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn limited_local_command_appends_playtest_to_existing_trace() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - fs::write( - root.join("game/index.html"), - fake_llm_game_draft().game_html, - ) - .expect("write playable game html"); - write_agent_run_trace( - &root, - "run-1", - "像素风横版动作", - "passed", - 1, - &[agent_trace_step( - 1, - "Generator", - "completed", - &[".agent/spec.md"], - &["game/index.html"], - "生成可运行原型", - "llm.chat.generator", - )], - None, - ) - .expect("run trace"); - - let result = run_limited_local_command( - root.to_string_lossy().into_owned(), - "game.static_smoke".to_string(), - ) - .expect("static smoke"); - - assert_eq!(result.status, "completed"); - let trace: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) - .expect("run trace json"); - assert_eq!(trace["status"], "passed"); - assert!(trace["steps"].as_array().unwrap().iter().any(|step| { - step["agent"] == "Playtest" - && step["phase"] == "playtest" - && step["taskId"] == "preview-readiness" - && step["toolCalls"][0]["toolId"] == "game.static_smoke" - })); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn agent_run_control_updates_lifecycle_and_jsonl() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_agent_run_trace( - &root, - "run-control-1", - "做一个可控 run", - "running", - 1, - &[agent_trace_step( - 1, - "Planner", - "completed", - &[".agent/manifest.json"], - &[".agent/spec.md"], - "已拆解目标", - "llm.chat.planner", - )], - None, - ) - .expect("run trace"); - - let killed = - update_agent_run_lifecycle(&root, "kill", None).expect("kill should update trace"); - assert_eq!(killed.status, "killed"); - assert_eq!(killed.lifecycle_status, "killed"); - assert_eq!(killed.next_step, "resume-or-retry"); - let trace: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) - .expect("run trace json"); - assert_eq!(trace["status"], "killed"); - assert_eq!(trace["lifecycleStatus"], "killed"); - assert_eq!(trace["stopReason"], "killed"); - - let retried = - update_agent_run_lifecycle(&root, "retry", None).expect("retry should mark pending"); - assert_eq!(retried.status, "pending"); - assert_eq!(retried.lifecycle_status, "pending"); - assert_eq!(retried.next_step, "rerun-now"); - let resumed = update_agent_run_lifecycle(&root, "resume", Some("继续修复输入监听")) - .expect("resume should mark pending"); - assert_eq!(resumed.status, "pending"); - assert_eq!(resumed.lifecycle_status, "pending"); - assert_eq!(resumed.next_step, "rerun-now"); - let trace: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) - .expect("run trace json after resume"); - assert_eq!(trace["stopReason"], "human-resume"); - assert_eq!(trace["error"], Value::Null); - let status = - update_agent_run_lifecycle(&root, "status", None).expect("status should read trace"); - assert_eq!(status.status, "pending"); - assert_eq!(status.lifecycle_status, "pending"); - let activity = fs::read_to_string(root.join(".agent/activity.jsonl")).expect("activity"); - assert!(activity.contains("agent.kill")); - assert!(activity.contains("agent.retry")); - assert!(activity.contains("agent.resume")); - assert!(activity.contains("agent.run_status")); - let output = fs::read_to_string(root.join(".agent/output.jsonl")).expect("output"); - assert!(output.contains("agent.kill")); - assert!(output.contains("agent.resume")); - assert!(root.join(".agent/context.bundle.json").exists()); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn agent_run_status_derives_missing_lifecycle_from_status() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_agent_run_trace( - &root, - "run-control-legacy", - "做一个旧 trace", - "passed", - 1, - &[agent_trace_step( - 1, - "Playtest", - "completed", - &["game/index.html"], - &[".agent/logs/command.log"], - "静态入口自检通过", - "game.static_smoke", - )], - None, - ) - .expect("run trace"); - let trace_path = root.join(".agent/run.latest.json"); - let mut trace: Value = - serde_json::from_str(&fs::read_to_string(&trace_path).unwrap()).expect("trace json"); - trace - .as_object_mut() - .expect("trace object") - .remove("lifecycleStatus"); - fs::write( - &trace_path, - serde_json::to_string_pretty(&trace).expect("trace json"), - ) - .expect("write legacy trace"); - - let status = - update_agent_run_lifecycle(&root, "status", None).expect("status should read trace"); - - assert_eq!(status.status, "passed"); - assert_eq!(status.lifecycle_status, "done"); - fs::remove_dir_all(root).ok(); - } - - #[tokio::test] - async fn agent_run_resume_restarts_generation_from_latest_goal() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_agent_run_trace( - &root, - "run-control-resume", - "做一个可恢复 run", - "killed", - 1, - &[agent_trace_step( - 1, - "Planner", - "completed", - &[".agent/manifest.json"], - &[".agent/spec.md"], - "已拆解目标", - "llm.chat.planner", - )], - Some("用户请求停止当前 run"), - ) - .expect("run trace"); - let responses = vec![ - "## 核心循环\n\n继续修复输入监听。\n\n## Evaluator 验收\n\n必须可运行。".to_string(), - serde_json::to_string(&fake_llm_game_draft()).expect("draft json"), - ]; - let (sender, receiver) = mpsc::channel(); - let base_url = spawn_mock_llm_server_responses_with_capture(responses, Some(sender)); - let _config_guard = write_test_local_config(format!( - r#"{{ - "llm": {{ - "apiKey": "test-key", - "baseUrl": {base_url:?}, - "model": "mock-game-model", - "apiKind": "openai_responses" - }} -}}"# - )); - - let result = control_agent_run_at(&root, "resume", Some("继续修复输入监听"), None) - .await - .expect("resume should rerun"); - - assert_eq!(result.status, "passed"); - assert_eq!(result.lifecycle_status, "done"); - assert_eq!(result.next_step, "preview-playtest"); - assert!(result.message.contains("已重新运行为")); - assert!(result.message.contains("game/index.html")); - let trace = read_latest_agent_run_trace(&root).expect("latest trace"); - assert_ne!(trace.run_id, "run-control-resume"); - assert!(trace.goal.contains("做一个可恢复 run")); - assert!(trace.goal.contains("继续说明:继续修复输入监听")); - let requests = receiver.try_iter().collect::>(); - assert!(requests - .first() - .expect("planner request") - .contains("继续说明:继续修复输入监听")); - let activity = fs::read_to_string(root.join(".agent/activity.jsonl")).expect("activity"); - assert!(activity.contains("agent.resume")); - assert!(activity.contains("agent.resume.run")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn limited_local_command_rejects_placeholder_game_smoke() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - let error = run_limited_local_command_at(&root, "game.static_smoke") - .expect_err("placeholder game should fail smoke"); - - assert!(error.contains("可渲染画布")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn limited_local_command_rejects_forbidden_runtime_apis() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - let html = fake_llm_game_draft() - .game_html - .replace("const marker =", "fetch('/secret');\n const marker ="); - fs::write(root.join("game/index.html"), html).expect("write game html"); - - let error = run_limited_local_command_at(&root, "game.static_smoke") - .expect_err("fetch should fail smoke"); - - assert!(error.contains("fetch(")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn limited_local_command_rejects_blank_canvas_game_smoke() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - let html = r#" - - - -

目标:点亮厨房。胜利 / 失败后按 R 重开。

- - -"#; - fs::write(root.join("game/index.html"), html).expect("write game html"); - - let error = run_limited_local_command_at(&root, "game.static_smoke") - .expect_err("blank canvas should fail smoke"); - - assert!(error.contains("canvas 上绘制画面")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn limited_local_command_rejects_unknown_command() { - let root = unique_project_path(); - let error = run_limited_local_command_at(&root, "npm.run.build") - .expect_err("unknown command should fail"); - - assert!(error.contains("不支持")); - } - - #[test] - fn preview_state_is_persisted_to_manifest() { - let root = unique_project_path(); - - record_preview_state( - &root, - GameCreationAppPreviewStatus::Running, - Some("http://127.0.0.1:3210/".to_string()), - Some(3210), - ) - .expect("preview state"); - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["preview"]["status"], "running"); - assert_eq!(manifest["preview"]["port"], 3210); - - record_preview_state(&root, GameCreationAppPreviewStatus::Stopped, None, None) - .expect("preview stopped"); - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["preview"]["status"], "stopped"); - assert!(manifest["preview"].get("url").is_none()); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn preview_start_appends_to_existing_agent_run_trace() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_agent_run_trace( - &root, - "run-1", - "像素风横版动作", - "passed", - 1, - &[agent_trace_step( - 1, - "Playtest", - "completed", - &["game/index.html"], - &[".agent/logs/command.log"], - "静态入口自检通过", - "game.static_smoke", - )], - None, - ) - .expect("run trace"); - - append_preview_start_trace_step( - &root, - &LocalPreviewResult { - url: "http://127.0.0.1:3210/".to_string(), - port: 3210, - root: root.join("game").to_string_lossy().into_owned(), - }, - ) - .expect("append preview trace"); - - let trace: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) - .expect("run trace json"); - assert_eq!(trace["status"], "preview-running"); - assert_eq!(trace["stopReason"], "preview-running"); - assert_eq!(trace["nextStep"], "manual-playtest"); - assert_eq!(trace["steps"].as_array().unwrap().len(), 2); - assert_eq!(trace["steps"][1]["agent"], "Preview"); - assert_eq!(trace["steps"][1]["toolCalls"][0]["toolId"], "preview.start"); - assert!(trace["steps"][1]["outputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == ".agent/logs/preview.log")); - assert!(trace["steps"][1]["summary"] - .as_str() - .unwrap() - .contains("http://127.0.0.1:3210/")); - let run_history: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/runs/run-1.json")).unwrap()) - .expect("run history json"); - assert_eq!(run_history["status"], "preview-running"); - assert_eq!(run_history["stopReason"], "preview-running"); - assert_eq!(run_history["nextStep"], "manual-playtest"); - assert_eq!(run_history["steps"].as_array().unwrap().len(), 2); - - append_preview_stop_trace_step(&root).expect("append preview stop trace"); - let trace: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) - .expect("run trace json"); - assert_eq!(trace["status"], "preview-stopped"); - assert_eq!(trace["stopReason"], "preview-stopped"); - assert_eq!(trace["nextStep"], "inspect-artifacts"); - assert_eq!(trace["steps"].as_array().unwrap().len(), 3); - assert_eq!(trace["steps"][2]["agent"], "Preview"); - assert_eq!(trace["steps"][2]["status"], "stopped"); - assert_eq!(trace["steps"][2]["toolCalls"][0]["toolId"], "preview.stop"); - assert!(trace["steps"][2]["outputPaths"] - .as_array() - .unwrap() - .iter() - .any(|path| path == ".agent/logs/preview.log")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn preview_log_records_start_and_stop_events() { - let root = unique_project_path(); - - append_preview_log(&root, "running", Some("http://127.0.0.1:3210/")) - .expect("preview start log"); - append_preview_log(&root, "stopped", None).expect("preview stop log"); - - let log = fs::read_to_string(root.join(".agent/logs/preview.log")).expect("preview log"); - assert!(log.contains("preview.running http://127.0.0.1:3210/")); - assert!(log.contains("preview.stopped")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn preview_start_trace_noops_without_agent_run_trace() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - - append_preview_start_trace_step( - &root, - &LocalPreviewResult { - url: "http://127.0.0.1:3210/".to_string(), - port: 3210, - root: root.join("game").to_string_lossy().into_owned(), - }, - ) - .expect("missing trace should not block preview"); - - assert!(!root.join(".agent/run.latest.json").exists()); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn preview_registry_reports_status_and_stops_previous_server() { - let registry = PreviewRegistry::default(); - assert_eq!(registry.status().status, "stopped"); - - let (first_stop, first_receiver) = mpsc::channel(); - let (_first_preview, previous) = registry.set_running( - LocalPreviewResult { - url: "http://127.0.0.1:1/".to_string(), - port: 1, - root: "/tmp/game-one/game".to_string(), - }, - first_stop, - ); - assert!(previous.is_none()); - assert_eq!(registry.status().port, Some(1)); - - let (second_stop, _second_receiver) = mpsc::channel(); - let (_second_preview, previous) = registry.set_running( - LocalPreviewResult { - url: "http://127.0.0.1:2/".to_string(), - port: 2, - root: "/tmp/game-two/game".to_string(), - }, - second_stop, - ); - assert!(first_receiver.try_recv().is_ok()); - assert_eq!( - previous.expect("previous preview").root, - "/tmp/game-one/game" - ); - assert_eq!(registry.status().port, Some(2)); - - assert_eq!(registry.stop().status, "stopped"); - assert_eq!(registry.status().status, "stopped"); - } - - #[test] - fn replaced_preview_records_stopped_state() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - record_preview_state( - &root, - GameCreationAppPreviewStatus::Running, - Some("http://127.0.0.1:3210/".to_string()), - Some(3210), - ) - .expect("preview running state"); - - record_replaced_preview_stop(&LocalPreviewResult { - url: "http://127.0.0.1:3210/".to_string(), - port: 3210, - root: root.to_string_lossy().into_owned(), - }); - - let manifest: Value = - serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) - .expect("manifest json"); - assert_eq!(manifest["preview"]["status"], "stopped"); - let log = fs::read_to_string(root.join(".agent/logs/preview.log")).expect("preview log"); - assert!(log.contains("preview.stopped")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn preview_registry_does_not_stop_other_project_preview() { - let first = unique_project_path(); - let second = unique_project_path(); - fs::create_dir_all(&first).expect("first project"); - fs::create_dir_all(&second).expect("second project"); - let registry = PreviewRegistry::default(); - let (first_stop, first_receiver) = mpsc::channel(); - registry.set_running( - LocalPreviewResult { - url: "http://127.0.0.1:1/".to_string(), - port: 1, - root: first.to_string_lossy().into_owned(), - }, - first_stop, - ); - - let (status, stopped) = registry.stop_for_project(Some(&second)); - - assert_eq!(status.status, "stopped"); - assert!(!stopped); - assert!(first_receiver.try_recv().is_err()); - assert_eq!(registry.status().port, Some(1)); - - let (_status, stopped) = registry.stop_for_project(Some(&first)); - assert!(stopped); - assert!(first_receiver.try_recv().is_ok()); - assert_eq!(registry.status().status, "stopped"); - - fs::remove_dir_all(first).ok(); - fs::remove_dir_all(second).ok(); - } - - #[test] - fn preview_stop_for_other_project_does_not_write_stopped_evidence() { - let first = unique_project_path(); - let second = unique_project_path(); - init_local_game_project_at(&first, "project-1", "预览项目一").expect("first project"); - init_local_game_project_at(&second, "project-2", "预览项目二").expect("second project"); - write_agent_run_trace( - &second, - "run-second", - "第二个项目", - "passed", - 1, - &[agent_trace_step( - 1, - "Playtest", - "completed", - &["game/index.html"], - &[".agent/logs/command.log"], - "静态入口自检通过", - "game.static_smoke", - )], - None, - ) - .expect("second trace"); - let registry = PreviewRegistry::default(); - let (first_stop, first_receiver) = mpsc::channel(); - registry.set_running( - LocalPreviewResult { - url: "http://127.0.0.1:1/".to_string(), - port: 1, - root: first.to_string_lossy().into_owned(), - }, - first_stop, - ); - - let status = stop_local_game_preview_for_root(Some(&second), ®istry) - .expect("stop other project preview"); - - assert_eq!(status.status, "stopped"); - assert!(first_receiver.try_recv().is_err()); - assert_eq!(registry.status().port, Some(1)); - let second_manifest: Value = - serde_json::from_str(&fs::read_to_string(second.join(".agent/manifest.json")).unwrap()) - .expect("second manifest"); - assert!(second_manifest.get("preview").is_none()); - assert!(!second.join(".agent/logs/preview.log").exists()); - let second_trace: Value = serde_json::from_str( - &fs::read_to_string(second.join(".agent/run.latest.json")).unwrap(), - ) - .expect("second trace"); - assert_eq!(second_trace["status"], "passed"); - assert!(second_trace["steps"] - .as_array() - .unwrap() - .iter() - .all(|step| step["toolCalls"][0]["toolId"] != "preview.stop")); - - stop_local_game_preview_for_root(Some(&first), ®istry).expect("cleanup first preview"); - assert!(first_receiver.try_recv().is_ok()); - fs::remove_dir_all(first).ok(); - fs::remove_dir_all(second).ok(); - } - - #[test] - fn preview_open_url_requires_running_localhost_preview() { - assert_eq!( - preview_open_url(&LocalPreviewStatus { - status: "running".to_string(), - url: Some("http://127.0.0.1:3001/".to_string()), - port: Some(3001), - root: Some("/tmp/game".to_string()), - }) - .unwrap(), - "http://127.0.0.1:3001/" - ); - - assert!(preview_open_url(&stopped_preview_status()).is_err()); - assert!(preview_open_url(&LocalPreviewStatus { - status: "running".to_string(), - url: Some("https://example.com/".to_string()), - port: Some(443), - root: Some("/tmp/game".to_string()), - }) - .is_err()); - } - - #[test] - fn preview_project_guard_rejects_other_project_preview() { - let first = unique_project_path(); - let second = unique_project_path(); - fs::create_dir_all(&first).expect("first project"); - fs::create_dir_all(&second).expect("second project"); - let status = LocalPreviewStatus { - status: "running".to_string(), - url: Some("http://127.0.0.1:3001/".to_string()), - port: Some(3001), - root: Some(first.to_string_lossy().into_owned()), - }; - - ensure_preview_belongs_to_project(&status, &first).expect("same project preview"); - let error = ensure_preview_belongs_to_project(&status, &second) - .expect_err("other project preview should fail"); - - assert!(error.contains("不属于已授权本地项目")); - - fs::remove_dir_all(first).ok(); - fs::remove_dir_all(second).ok(); - } - - #[test] - fn preview_open_respects_project_policy_when_project_is_provided() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["preview.open".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - let status = LocalPreviewStatus { - status: "running".to_string(), - url: Some("http://127.0.0.1:3210/".to_string()), - port: Some(3210), - root: Some(root.to_string_lossy().into_owned()), - }; - - let error = validate_preview_open_project(&status, Some(root.to_str().unwrap())) - .expect_err("preview.open denied"); - assert!(error.contains("项目权限策略拒绝执行:preview.open")); - validate_preview_open_project(&status, None).expect("global preview open allowed"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn preview_status_respects_project_policy_when_project_is_provided() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["preview.status".to_string()], - confirm_commands: Vec::new(), - }, - ) - .expect("write policy"); - let registry = PreviewRegistry::default(); - - let error = get_local_game_preview_status_at(®istry, Some(root.to_str().unwrap())) - .expect_err("preview.status denied"); - assert!(error.contains("项目权限策略拒绝执行:preview.status")); - - let global_status = - get_local_game_preview_status_at(®istry, None).expect("global status allowed"); - assert_eq!(global_status.status, "stopped"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn preview_status_filter_hides_other_project_preview() { - let first = unique_project_path(); - let second = unique_project_path(); - fs::create_dir_all(&first).expect("first project"); - fs::create_dir_all(&second).expect("second project"); - let status = LocalPreviewStatus { - status: "running".to_string(), - url: Some("http://127.0.0.1:3001/".to_string()), - port: Some(3001), - root: Some(first.to_string_lossy().into_owned()), - }; - - assert_eq!( - filter_preview_status_for_project(status.clone(), Some(first.to_str().unwrap())).status, - "running" - ); - assert_eq!( - filter_preview_status_for_project(status, Some(second.to_str().unwrap())).status, - "stopped" - ); - - fs::remove_dir_all(first).ok(); - fs::remove_dir_all(second).ok(); - } - - #[test] - fn preview_path_rejects_traversal() { - let project = unique_project_path(); - fs::create_dir_all(project.join("game")).expect("game dir"); - fs::create_dir_all(project.join("assets")).expect("assets dir"); - fs::create_dir_all(project.join("memory")).expect("memory dir"); - fs::create_dir_all(project.join(".agent")).expect("agent dir"); - fs::write(project.join("game/index.html"), "").expect("index"); - fs::write(project.join("assets/player.png"), b"png").expect("asset"); - fs::write(project.join("memory/project.md"), "secret memory").expect("memory"); - fs::write(project.join(".agent/run.latest.json"), "{}").expect("trace"); - - assert_eq!( - resolve_preview_path(&project, "/").unwrap(), - project - .join("game/index.html") - .canonicalize() - .expect("canonical index") - ); - assert!(resolve_preview_path(&project, "/../secret.txt").is_err()); - assert!(resolve_preview_path(&project, "/%2e%2e/secret.txt").is_err()); - assert!(resolve_preview_path(&project, "/memory/project.md").is_err()); - assert!(resolve_preview_path(&project, "/.agent/run.latest.json").is_err()); - assert_eq!( - resolve_preview_path(&project, "/assets/player.png?cache=1").unwrap(), - project - .join("assets/player.png") - .canonicalize() - .expect("canonical asset") - ); - - fs::remove_dir_all(project).ok(); - } - - #[cfg(unix)] - #[test] - fn preview_path_rejects_symlink_escape() { - use std::os::unix::fs::symlink; - - let project = unique_project_path(); - let outside_secret = project.with_extension("secret.txt"); - fs::create_dir_all(project.join("game")).expect("game dir"); - fs::create_dir_all(project.join("assets")).expect("assets dir"); - fs::write(project.join("game/index.html"), "").expect("index"); - fs::write(&outside_secret, "secret").expect("secret"); - symlink(&outside_secret, project.join("assets/leak.txt")).expect("symlink"); - - assert!(resolve_preview_path(&project, "/assets/leak.txt").is_err()); - - fs::remove_file(outside_secret).ok(); - fs::remove_dir_all(project).ok(); - } - - #[cfg(unix)] - #[test] - fn preview_path_rejects_symlink_to_private_project_dirs() { - use std::os::unix::fs::symlink; - - let project = unique_project_path(); - fs::create_dir_all(project.join("game")).expect("game dir"); - fs::create_dir_all(project.join("assets")).expect("assets dir"); - fs::create_dir_all(project.join("memory")).expect("memory dir"); - fs::write(project.join("game/index.html"), "").expect("index"); - fs::write(project.join("memory/project.md"), "secret memory").expect("memory"); - symlink( - project.join("memory/project.md"), - project.join("assets/memory-link.md"), - ) - .expect("memory symlink"); - - assert!(resolve_preview_path(&project, "/assets/memory-link.md").is_err()); - - fs::remove_dir_all(project).ok(); - } - - #[cfg(unix)] - #[test] - fn preview_path_rejects_symlinked_allowed_root_dir() { - use std::os::unix::fs::symlink; - - let project = unique_project_path(); - fs::create_dir_all(project.join("game")).expect("game dir"); - fs::create_dir_all(project.join("memory")).expect("memory dir"); - fs::write(project.join("game/index.html"), "").expect("index"); - fs::write(project.join("memory/project.md"), "secret memory").expect("memory"); - symlink(project.join("memory"), project.join("assets")).expect("assets symlink"); - - assert!(resolve_preview_path(&project, "/assets/project.md").is_err()); - - fs::remove_dir_all(project).ok(); - } - - #[test] - fn launcher_window_uses_launcher_route() { - assert_eq!(launcher_window_url().to_string(), "index.html?launcher"); - } - - #[test] - fn workspace_window_url_carries_encoded_project_path() { - assert_eq!( - workspace_window_url("/tmp/AI Game 项目").to_string(), - "index.html?main&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE" - ); - } - - #[test] - fn workspace_window_project_path_requires_absolute_path() { - assert!(validate_workspace_window_project_path(" /tmp/game ").is_ok()); - assert!(validate_workspace_window_project_path("relative-game") - .expect_err("relative path should be rejected") - .contains("绝对路径")); - assert!(validate_workspace_window_project_path(" ") - .expect_err("empty path should be rejected") - .contains("绝对路径")); - assert!(validate_workspace_window_project_path("/tmp/game\nnext") - .expect_err("control character path should be rejected") - .contains("控制字符")); - } - - #[test] - fn cli_agent_run_requires_project_and_prompt() { - assert_eq!( - parse_cli_command(&["--llm-status".to_string()]) - .expect("parse llm status") - .expect("llm status command"), - CliCommand::LlmStatus - ); - let args = vec![ - "--agent-run".to_string(), - "/tmp/genarrative-cli-game".to_string(), - "做一个弹幕厨房".to_string(), - "带反弹".to_string(), - ]; - let command = parse_cli_command(&args) - .expect("parse cli") - .expect("cli command"); - - assert_eq!( - command, - CliCommand::AgentRun { - project_path: PathBuf::from("/tmp/genarrative-cli-game"), - prompt: "做一个弹幕厨房 带反弹".to_string(), - wait_for_enter: true, - } - ); - let no_wait = parse_cli_command(&[ - "--agent-run".to_string(), - "--no-wait".to_string(), - "/tmp/genarrative-cli-game".to_string(), - "做一个弹幕厨房".to_string(), - ]) - .expect("parse no wait") - .expect("cli command"); - assert_eq!( - no_wait, - CliCommand::AgentRun { - project_path: PathBuf::from("/tmp/genarrative-cli-game"), - prompt: "做一个弹幕厨房".to_string(), - wait_for_enter: false, - } - ); - assert!(parse_cli_command(&[]).expect("parse no cli").is_none()); - assert!(parse_cli_command(&["--agent-run".to_string()]).is_err()); - } - - #[test] - fn local_preview_server_serves_game_index() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join("assets/player.png"), b"PNGDATA").expect("asset"); - - let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start"); - let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); - stream - .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") - .expect("request"); - let mut response = String::new(); - stream.read_to_string(&mut response).expect("response"); - - assert!(response.contains("200 OK"), "{response}"); - assert!(response.contains("还没有生成游戏")); - - let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); - stream - .write_all(b"GET /assets/player.png HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") - .expect("asset request"); - let mut response = String::new(); - stream - .read_to_string(&mut response) - .expect("asset response"); - - assert!(response.contains("200 OK"), "{response}"); - assert!(response.contains("PNGDATA"), "{response}"); - assert_eq!(preview.root, root.to_string_lossy()); - - let _ = stop.send(()); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn preview_content_type_covers_common_game_assets() { - assert_eq!(content_type(Path::new("hero.webp")), "image/webp"); - assert_eq!(content_type(Path::new("cover.jpg")), "image/jpeg"); - assert_eq!(content_type(Path::new("bgm.mp3")), "audio/mpeg"); - assert_eq!(content_type(Path::new("hit.wav")), "audio/wav"); - assert_eq!(content_type(Path::new("intro.mp4")), "video/mp4"); - } - - #[test] - fn local_preview_head_preserves_asset_content_length() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join("assets/player.png"), b"PNGDATA").expect("asset"); - - let response = build_preview_response(&root, "HEAD", "/assets/player.png"); - let response = String::from_utf8(response).expect("head response"); - - assert!(response.contains("200 OK"), "{response}"); - assert!(response.contains("Content-Type: image/png"), "{response}"); - assert!(response.contains("Content-Length: 7"), "{response}"); - assert!(!response.contains("PNGDATA"), "{response}"); - assert!(response.ends_with("\r\n\r\n"), "{response}"); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn local_preview_serves_generated_playable_game() { - let root = unique_project_path(); - write_local_game_draft_at(&root, "像素风横版动作", &fake_llm_game_draft()) - .expect("draft should generate"); - - let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start"); - let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); - stream - .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") - .expect("request"); - let mut response = String::new(); - stream.read_to_string(&mut response).expect("response"); - - assert!(response.contains("200 OK"), "{response}"); - assert!(response.contains("requestAnimationFrame(frame)")); - assert!(response.contains("MOCK_UNIQUE_MECHANIC:moon-kitchen-reflect")); - assert!(response.contains("月光弹幕厨房")); - assert!(response.contains("player.hp")); - assert!(!response.contains("