并行拆分客户端生成与验收大型模块

拆分 tool-plan 持久交接的模型、校验、发现和跨平台存储模块。

拆分旧生成链的提示、画布、产物、生命周期和追踪模块。

拆分 Swarm CLI 的输入、观察、终态判定、报告与测试模块。

拆分浏览器发现、CDP、网络策略、试玩、证据与测试模块。

补充兼容可见性、集成验证和第三轮拆分文档。
This commit is contained in:
AIGameCreator App
2026-07-22 18:28:19 +08:00
parent e142ef5eac
commit 9371cc537a
47 changed files with 19020 additions and 18576 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,394 @@
use super::*;
pub(crate) fn evaluate_game_draft(prompt: &str, draft: &LlmGameDraft) -> Vec<String> {
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<str>]) -> String {
let issue_values = issues
.iter()
.map(|issue| issue.as_ref().trim())
.filter(|issue| !issue.is_empty())
.collect::<Vec<_>>();
let actionable_issues = issue_values
.iter()
.filter(|issue| !issue.contains("暂无上一轮问题"))
.map(|issue| (*issue).to_string())
.collect::<Vec<_>>();
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 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<LlmGameDraft, String> {
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::<LlmGameDraft>(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("<think>") else {
output.push_str(rest);
break;
};
output.push_str(&rest[..start]);
let after_start = &rest[start + "<think>".len()..];
let Some(end) = after_start.to_ascii_lowercase().find("</think>") else {
break;
};
rest = &after_start[end + "</think>".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 mut depth = 0usize;
let mut inside_string = false;
let mut escaped = false;
for (offset, character) in without_fence[start..].char_indices() {
if inside_string {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
inside_string = false;
}
continue;
}
match character {
'"' => inside_string = true,
'{' => depth += 1,
'}' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
let end = start + offset + character.len_utf8();
return Some(&without_fence[start..end]);
}
}
_ => {}
}
}
None
}
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") && !lower_html.contains("<!doctype html") {
return Err("LLM 草案 gameHtml 必须是完整 HTML".to_string());
}
if !html.contains("requestAnimationFrame") {
return Err("LLM 草案 gameHtml 必须包含游戏主循环".to_string());
}
if !lower_html.contains("<canvas") {
return Err("LLM 草案 gameHtml 必须包含可渲染画布".to_string());
}
validate_canvas_rendering_html(html, "LLM 草案 gameHtml")?;
validate_safe_game_html_runtime(html, "LLM 草案 gameHtml")?;
if (prompt.contains('<') || prompt.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("<script src")
|| lower_html.contains("http://")
|| lower_html.contains("https://")
{
return Err(format!("{label} 必须自包含,不能加载远程脚本或资源"));
}
for forbidden in [
"eval(",
"new function",
"localstorage",
"fetch(",
"websocket",
"serviceworker",
] {
if lower_html.contains(forbidden) {
return Err(format!("{label} 不能使用 {forbidden}"));
}
}
Ok(())
}
pub(crate) fn validate_game_html_smoke(html: &str) -> Result<(), String> {
let lower_html = html.to_ascii_lowercase();
if !lower_html.contains("<canvas") {
return Err("游戏入口必须包含可渲染画布".to_string());
}
validate_closed_game_script_blocks(&lower_html)?;
validate_canvas_rendering_html(html, "游戏入口")?;
if !html.contains("requestAnimationFrame") {
return Err("游戏入口必须包含游戏主循环".to_string());
}
if !contains_any(
&lower_html,
&["keydown", "keyup", "pointer", "mousedown", "touch", "click"],
) {
return Err("游戏入口必须包含键盘、鼠标或触摸输入监听".to_string());
}
validate_safe_game_html_runtime(html, "游戏入口")?;
validate_playable_game_html(html, "游戏入口")?;
validate_non_placeholder_game_html(html, "游戏入口")
}
pub(in crate::agent) fn validate_closed_game_script_blocks(lower_html: &str) -> Result<(), String> {
fn next_script_tag(source: &str, from: usize, closing: bool) -> Option<usize> {
let pattern = if closing { "</script" } else { "<script" };
source[from..]
.match_indices(pattern)
.find_map(|(offset, _)| {
let index = from + offset;
let boundary = source.as_bytes().get(index + pattern.len()).copied();
matches!(
boundary,
Some(b'>') | Some(b'/') | Some(b' ') | Some(b'\t') | Some(b'\r') | Some(b'\n')
)
.then_some(index)
})
}
let mut cursor = 0;
while let Some(opening) = next_script_tag(lower_html, cursor, false) {
if next_script_tag(lower_html, cursor, true).is_some_and(|closing| closing < opening) {
return Err("游戏入口包含没有对应开始标签的 </script>".to_string());
}
let opening_end = lower_html[opening..]
.find('>')
.map(|offset| opening + offset + 1)
.ok_or_else(|| "游戏入口的 <script> 开始标签未闭合".to_string())?;
let closing = next_script_tag(lower_html, opening_end, true)
.ok_or_else(|| "游戏入口的 <script> 代码块未闭合,疑似源码被截断".to_string())?;
cursor = lower_html[closing..]
.find('>')
.map(|offset| closing + offset + 1)
.ok_or_else(|| "游戏入口的 </script> 结束标签未闭合".to_string())?;
}
if next_script_tag(lower_html, cursor, true).is_some() {
return Err("游戏入口包含没有对应开始标签的 </script>".to_string());
}
Ok(())
}
pub(crate) fn validate_canvas_rendering_html(html: &str, label: &str) -> Result<(), String> {
let lower_html = html.to_ascii_lowercase();
if !lower_html.contains("getcontext(") && !lower_html.contains(".getcontext") {
return Err(format!("{label} 必须获取 canvas 渲染上下文"));
}
if !contains_any(
&lower_html,
&[
"fillrect(",
"strokerect(",
"drawimage(",
"filltext(",
".arc(",
".fill(",
".stroke(",
"putimagedata(",
"drawarrays(",
"drawelements(",
],
) {
return Err(format!("{label} 必须在 canvas 上绘制画面"));
}
Ok(())
}
pub(crate) fn validate_non_placeholder_game_html(html: &str, label: &str) -> Result<(), String> {
let lower_html = html.to_ascii_lowercase();
for forbidden in [
"星核传送门",
"点击按钮加分",
"点击按钮得分",
"todo:",
"待实现",
"这里省略",
] {
if lower_html.contains(forbidden) {
return Err(format!("{label} 不能是固定模板或未完成实现:{forbidden}"));
}
}
let compact = lower_html
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>();
if compact.contains("addeventlistener(")
&& (compact.contains("=>{})") || compact.contains("function(){}"))
{
return Err(format!("{label} 的输入监听不能是空实现"));
}
Ok(())
}
pub(crate) fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
pub(crate) fn validate_llm_agent_handoffs(draft: &LlmGameDraft) -> Result<(), String> {
const REQUIRED_GROUPS: [&str; 6] = ["design", "balance", "art", "audio", "code", "publishing"];
for required_group in REQUIRED_GROUPS {
let handoff = draft
.handoffs
.iter()
.find(|handoff| handoff.group.trim() == required_group)
.ok_or_else(|| format!("LLM 草案 handoffs 缺少 {required_group} 专业组"))?;
if handoff.role.trim().is_empty()
|| handoff.summary.trim().is_empty()
|| handoff.next.trim().is_empty()
|| handoff
.outputs
.iter()
.all(|output| output.trim().is_empty())
{
return Err(format!("LLM 草案 handoffs.{required_group} 交接内容不完整"));
}
}
Ok(())
}
@@ -0,0 +1,136 @@
use super::*;
pub(crate) fn write_local_game_draft_at(
root: &Path,
prompt: &str,
draft: &LlmGameDraft,
) -> Result<GenerateLocalGameDraftResult, String> {
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,
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,471 @@
use super::*;
pub(crate) fn write_agent_pass_agenda(
root: &Path,
pass: u8,
findings_markdown: &str,
) -> Result<AgentPassAgenda, String> {
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::<Vec<_>>();
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<String>],
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<String>],
issues: &[String],
repair_routes: &[GameCreationAgentRepairRouteTrace],
) -> Result<String, String> {
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<Option<(String, String)>, 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<AgentPassArtifactPaths, String> {
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<String, String> {
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<String, String> {
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<String, String> {
if task_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
return Ok(GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH.to_string());
}
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<GameCreationAgentRunStep>,
) {
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::<Vec<_>>();
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",
));
}
}
@@ -0,0 +1,413 @@
use super::*;
pub(crate) fn game_creator_system_prompt() -> &'static str {
r#"你是 Genarrative 的 AI 游戏创作 Generator。你必须读取 Planner 规格、六个专业组 agent brief(这些 brief 已由组内 Director / Gameplay / Asset / Code / Preview / Playtest / Polish / Publish 等角色分别产出并汇总)和 Evaluator findings,把它们整合为一个本地可运行 Web 游戏原型。只返回 JSON,不返回 Markdown 解释。
JSON schema:
{
"title": "游戏标题",
"designMarkdown": "策划组输出,包含核心循环、输入、胜负条件、关卡目标",
"balance": { "playerSpeed": 180, "playerLives": 3, "difficultyRamp": "..." },
"artManifest": { "source": "llm", "items": [ { "kind": "character|scene|ui|animation", "title": "...", "status": "needs-canvas|generated" } ] },
"audioManifest": { "source": "llm", "items": [ { "kind": "background-music|sound-effect", "title": "...", "status": "needs-canvas|generated" } ] },
"publishReadme": "运营组输出,包含标题、简介、标签、封面需求和下一步验收",
"handoffs": [
{ "group": "design", "role": "Gameplay", "summary": "策划交接摘要", "outputs": ["game/game_design.md"], "next": "交给数值、美术、音乐、程序组" },
{ "group": "balance", "role": "Difficulty", "summary": "数值交接摘要", "outputs": ["game/balance.json"], "next": "交给程序组读取" },
{ "group": "art", "role": "Asset", "summary": "美术交接摘要", "outputs": ["assets/manifest.art.json"], "next": "进入画板或本地资产登记" },
{ "group": "audio", "role": "SFX", "summary": "音乐音效交接摘要", "outputs": ["assets/manifest.audio.json"], "next": "进入画板音频链路" },
{ "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html"], "next": "交给 Playtest" },
{ "group": "publishing", "role": "Publish", "summary": "运营交接摘要", "outputs": ["exports/README.md"], "next": "等待预览验收" }
],
"handoffSummary": "六组 agent 的交接摘要,每组一行",
"gameHtml": "完整自包含 HTML,可直接保存为 game/index.html"
}
gameHtml 规则:
- 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。
- 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。
- JavaScript 不要 eval、Function、localStorage、fetch、WebSocket、ServiceWorker。
- 玩法、文本、数值和视觉主题必须明显响应用户需求、Planner 规格和组内角色 brief,不要输出固定星核传送门模板。
"#
}
pub(crate) fn game_creator_planner_system_prompt() -> &'static str {
r#"你是 Genarrative 的 AI 游戏创作 Planner。输出一份给 Generator 使用的 Markdown 规格,不要生成代码。规格必须包含:核心循环、输入方式、胜负条件、首版关卡、6 个专业组分工、组内角色任务矩阵、Evaluator 验收标准。不要写客套说明。"#
}
pub(crate) fn game_creator_planner_user_prompt(
prompt: &str,
short_memory: &str,
long_memory: &str,
project_blackboard: &str,
) -> String {
format!(
"用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\n项目黑板:\n{}\n\n请输出 Planner 规格 Markdown。",
prompt.trim(),
truncate_prompt_context(short_memory),
truncate_prompt_context(long_memory),
truncate_prompt_context(project_blackboard)
)
}
pub(crate) fn game_creator_generator_user_prompt(
prompt: &str,
short_memory: &str,
long_memory: &str,
project_blackboard: &str,
spec_markdown: &str,
findings_markdown: &str,
group_briefs_markdown: &str,
agenda_markdown: &str,
) -> String {
format!(
"用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\n项目黑板:\n{}\n\nPlanner 规格文件 .agent/spec.md\n{}\n\n本轮 Orchestrator agenda\n{}\n\n六个专业组 agent brief(每组由组内角色汇总而成):\n{}\n\nEvaluator 反馈文件 .agent/findings.md\n{}\n\n请直接返回满足 schema 的 JSON。如果 findings 有问题,必须优先修复 agenda 中 activeTasks 对应的任务。",
prompt.trim(),
truncate_prompt_context(short_memory),
truncate_prompt_context(long_memory),
truncate_prompt_context(project_blackboard),
truncate_prompt_context(spec_markdown),
truncate_prompt_context(agenda_markdown),
truncate_prompt_context(group_briefs_markdown),
truncate_prompt_context(findings_markdown)
)
}
pub(crate) fn append_prompt_context(base: &str, extra: &str) -> String {
match (base.trim().is_empty(), extra.trim().is_empty()) {
(_, true) => base.to_string(),
(true, false) => extra.trim().to_string(),
(false, false) => format!("{}\n\n{}", base.trim_end(), extra.trim()),
}
}
pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, String> {
let manifest = read_manifest_for_project(root)?;
if manifest.assets.is_empty() {
return Ok(String::new());
}
let mut output = "# 本地项目资产\n\n".to_string();
for asset in manifest.assets.iter().take(24) {
output.push_str("- ");
output.push_str(&asset.id);
output.push_str(": ");
output.push_str(&asset.kind);
output.push_str(" / ");
output.push_str(&asset.media_type);
output.push_str(" / ");
output.push_str(&asset.local_path);
output.push_str(" / source=");
output.push_str(asset_source_kind_label(&asset.source.kind));
if let Some(canvas_project_id) = asset.source.canvas_project_id.as_deref() {
output.push_str(" / canvasProjectId=");
output.push_str(canvas_project_id);
}
if let Some(resource_id) = asset.source.resource_id.as_deref() {
output.push_str(" / resourceId=");
output.push_str(resource_id);
}
if let Some(asset_object_id) = asset.source.asset_object_id.as_deref() {
output.push_str(" / assetObjectId=");
output.push_str(asset_object_id);
}
if let Some(task_id) = asset.source.task_id.as_deref() {
output.push_str(" / taskId=");
output.push_str(task_id);
}
if let Some(model) = asset.source.model.as_deref() {
output.push_str(" / model=");
output.push_str(model);
}
output.push('\n');
}
if manifest.assets.len() > 24 {
output.push_str(&format!(
"- ... 还有 {} 个资产\n",
manifest.assets.len() - 24
));
}
Ok(output)
}
pub(crate) fn render_local_conversation_prompt_context(
root: &Path,
agent_id: Option<&str>,
) -> Result<String, String> {
render_local_conversation_prompt_context_for_session(root, agent_id, None)
}
pub(crate) fn render_local_conversation_prompt_context_for_session(
root: &Path,
agent_id: Option<&str>,
session_id: Option<&str>,
) -> Result<String, String> {
#[derive(Debug)]
struct ConversationPromptEntry {
updated_at: u64,
agent_label: String,
role: String,
content: String,
}
fn push_conversation_entries(
entries: &mut Vec<ConversationPromptEntry>,
conversation: LocalConversationResult,
agent_label: &str,
) {
for message in conversation.messages {
let content = sanitize_prompt_context(&message.content)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if content.is_empty() {
continue;
}
entries.push(ConversationPromptEntry {
updated_at: message.updated_at,
agent_label: agent_label.to_string(),
role: message.role,
content,
});
}
}
validate_project_root(root)?;
let mut entries = Vec::new();
push_conversation_entries(
&mut entries,
read_local_conversation_for_session_at(root, None, None)?,
"project",
);
if let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) {
if agent_id == "*" {
if session_id
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return Err("读取全部 Agent 对话时不能指定单个 sessionId".to_string());
}
let agents_dir = root.join(".agent/conversations/agents");
match fs::read_dir(&agents_dir) {
Ok(read_dir) => {
let mut agent_ids = std::collections::BTreeSet::new();
for entry in read_dir {
let entry = entry.map_err(|error| {
format!("读取 Agent 对话目录失败:{}: {error}", agents_dir.display())
})?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) == Some("jsonl") {
if let Some(agent_id) =
path.file_stem().and_then(|value| value.to_str())
{
agent_ids.insert(agent_id.to_string());
}
} else if path.is_dir() {
if let Some(agent_id) =
path.file_name().and_then(|value| value.to_str())
{
agent_ids.insert(agent_id.to_string());
}
}
}
for agent_id in agent_ids {
push_conversation_entries(
&mut entries,
read_local_conversation_for_session_at(root, Some(&agent_id), None)?,
&agent_id,
);
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取 Agent 对话目录失败:{}: {error}",
agents_dir.display()
));
}
}
} else {
push_conversation_entries(
&mut entries,
read_local_conversation_for_session_at(root, Some(agent_id), session_id)?,
agent_id,
);
}
} else if session_id
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return Err("项目主对话不接受 sessionId".to_string());
}
if entries.is_empty() {
return Ok(String::new());
}
entries.sort_by(|left, right| {
right
.updated_at
.cmp(&left.updated_at)
.then_with(|| left.agent_label.cmp(&right.agent_label))
.then_with(|| left.role.cmp(&right.role))
});
entries.truncate(GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES);
entries.sort_by(|left, right| {
left.updated_at
.cmp(&right.updated_at)
.then_with(|| left.agent_label.cmp(&right.agent_label))
.then_with(|| left.role.cmp(&right.role))
});
let mut output = "# 最近对话上下文\n\n".to_string();
for entry in entries {
output.push_str(&format!(
"- [{} / {}] {}\n",
entry.agent_label, entry.role, entry.content
));
}
Ok(output)
}
pub(crate) fn asset_source_kind_label(kind: &GameCreationAppAssetSourceKind) -> &'static str {
match kind {
GameCreationAppAssetSourceKind::Uploaded => "uploaded",
GameCreationAppAssetSourceKind::Generated => "generated",
GameCreationAppAssetSourceKind::Canvas => "canvas",
}
}
pub(crate) fn truncate_prompt_context(value: &str) -> String {
const MAX_CHARS: usize = 2400;
let sanitized = sanitize_prompt_context(value);
let trimmed = sanitized.trim();
let mut output = trimmed.chars().take(MAX_CHARS).collect::<String>();
if trimmed.chars().count() > MAX_CHARS {
output.push_str("\n...<truncated>");
}
output
}
pub(crate) fn truncate_prompt_context_preserving_tail(value: &str) -> String {
const MAX_CHARS: usize = 2400;
let sanitized = sanitize_prompt_context(value);
let trimmed = sanitized.trim();
if trimmed.chars().count() <= MAX_CHARS {
return trimmed.to_string();
}
let mut characters = trimmed.chars().rev().take(MAX_CHARS).collect::<Vec<_>>();
characters.reverse();
format!(
"...<truncated>\n{}",
characters.into_iter().collect::<String>()
)
}
pub(crate) fn sanitize_prompt_context(value: &str) -> String {
let mut sanitized = Vec::new();
let mut inside_private_key = false;
for line in value.lines() {
let lower = line.to_ascii_lowercase();
if inside_private_key {
if lower.contains("-----end") && lower.contains("private key") {
inside_private_key = false;
}
continue;
}
if lower.contains("-----begin") && lower.contains("private key") {
sanitized.push("[redacted sensitive context]".to_string());
inside_private_key = true;
continue;
}
if lower.contains(".env")
|| lower.contains("game-creator.config")
|| lower.contains("authorization:")
|| lower.contains("cookie:")
|| lower.contains("api_key")
|| lower.contains("apikey")
|| lower.contains("api key")
|| lower.contains("x-api-key")
|| lower.contains("x_api_key")
|| lower.contains("client_secret")
|| lower.contains("clientsecret")
|| lower.contains("access_token")
|| lower.contains("accesstoken")
|| lower.contains("refresh_token")
|| lower.contains("refreshtoken")
|| lower.contains("password=")
|| lower.contains("password:")
|| lower.contains("\"password\"")
|| lower.contains("--password")
|| lower.contains("--api-key")
|| lower.contains("--apikey")
|| lower.contains("--token")
|| lower.contains("--secret")
|| lower.contains("secret=")
|| lower.contains("token=")
|| lower.contains("\"token\"")
|| lower.contains("bearer ")
{
sanitized.push("[redacted sensitive context]".to_string());
} else {
sanitized.push(redact_secret_tokens(line));
}
}
sanitized.join("\n")
}
pub(crate) fn redact_secret_tokens(line: &str) -> String {
let mut spans = [
("tnr_sk_", 8usize),
("sk-", 8),
("ghp_", 20),
("gho_", 20),
("ghu_", 20),
("ghs_", 20),
("ghr_", 20),
("npm_", 20),
("AKIA", 16),
("ASIA", 16),
("AIza", 20),
("sk_live_", 16),
("rk_live_", 16),
("xoxb-", 16),
("xoxp-", 16),
("xoxa-", 16),
("xoxr-", 16),
]
.into_iter()
.flat_map(|(prefix, minimum_body_length)| {
line.match_indices(prefix).filter_map(move |(index, _)| {
agent_runtime_secret_token_end_with_minimum(line, index, prefix, minimum_body_length)
.map(|token_end| (index, token_end))
})
})
.collect::<Vec<_>>();
spans.extend(line.match_indices("eyJ").filter_map(|(index, _)| {
agent_runtime_jwt_token_end(line, index).map(|token_end| (index, token_end))
}));
if spans.is_empty() {
return line.to_string();
}
spans.sort_unstable_by_key(|(start, end)| (*start, *end));
let mut output = String::with_capacity(line.len());
let mut cursor = 0usize;
for (start, end) in spans {
if start < cursor {
continue;
}
output.push_str(&line[cursor..start]);
output.push_str("[redacted-secret]");
cursor = end;
}
output.push_str(&line[cursor..]);
output
}
pub(crate) fn read_optional_text(path: &Path) -> Result<String, String> {
match fs::read_to_string(path) {
Ok(content) => Ok(content),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(format!("读取上下文失败:{}: {error}", path.display())),
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,393 @@
use super::*;
pub(crate) fn update_agent_run_lifecycle(
root: &Path,
action: &str,
detail: Option<&str>,
) -> Result<AgentRunControlResult, String> {
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<AgentRunControlResult, String> {
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<GameCreationAgentRunTrace, String> {
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::<GameCreationAgentRunTrace>(&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<AgentRunControlResult, String> {
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<u16, String> {
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::<GameCreationAgentRunTrace>(&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<Vec<GameCreationAgentArtifactTrace>, String> {
let mut relative_paths = GAME_CREATOR_AGENT_ARTIFACT_PATHS
.iter()
.map(|path| (*path).to_string())
.collect::<Vec<_>>();
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::<Result<Vec<_>, _>>()
.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::<Result<Vec<_>, _>>()
.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::<Result<Vec<_>, _>>()
.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)
}
@@ -0,0 +1,25 @@
use super::*;
#[cfg(test)]
pub(crate) async fn request_llm_game_draft_with_client(
client: &LlmClient,
prompt: &str,
short_memory: &str,
long_memory: &str,
) -> Result<LlmGameDraft, String> {
let llm = GameCreatorLlmConfig::default();
request_generator_game_draft_with_client(
client,
&llm,
prompt,
short_memory,
long_memory,
"",
"",
"",
"",
"",
None,
)
.await
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,277 @@
use std::time::Duration;
use chromiumoxide::browser::Browser;
use chromiumoxide::cdp::browser_protocol::browser::{
SetDownloadBehaviorBehavior, SetDownloadBehaviorParams,
};
use chromiumoxide::cdp::browser_protocol::emulation::{
SetDeviceMetricsOverrideParams, SetTouchEmulationEnabledParams,
};
use chromiumoxide::cdp::browser_protocol::network::SetBypassServiceWorkerParams;
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::page::ScreenshotParams;
use url::Url;
use super::capture::{
build_snapshot_script, canvas_validation_diagnostic, sanitize_url, start_capture_tasks,
truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RESTRICTION_SCRIPT,
};
use super::evidence::write_atomic;
use super::model::{
BrowserExpectedTextMatch, BrowserIdentity, BrowserPlaytestResult,
BrowserValidationEvidencePaths, BrowserValidationInput, BrowserValidationResult,
BrowserValidationViewport, BrowserViewportValidationResult, DiscoveredBrowser, BROWSER_TIMEOUT,
REQUIRED_VIEWPORTS, RESULT_SCHEMA_VERSION,
};
use super::network_policy::same_preview_origin;
use super::playtest::run_desktop_playtest;
struct BrowserViewportValidationOutcome {
result: BrowserViewportValidationResult,
playtest: Option<BrowserPlaytestResult>,
}
pub(super) async fn run_browser_validation(
browser: &Browser,
discovered: &DiscoveredBrowser,
preview_url: &Url,
input: &BrowserValidationInput,
) -> Result<BrowserValidationResult, String> {
browser
.execute(SetDownloadBehaviorParams::new(
SetDownloadBehaviorBehavior::Deny,
))
.await
.map_err(|error| format!("禁用浏览器下载失败:{error}"))?;
let version = browser
.version()
.await
.map_err(|error| format!("读取浏览器版本失败:{error}"))?;
let mut viewport_results = Vec::with_capacity(REQUIRED_VIEWPORTS.len());
let mut playtest = None;
for viewport in REQUIRED_VIEWPORTS {
let outcome = validate_viewport(browser, preview_url, input, viewport).await?;
if outcome.playtest.is_some() {
playtest = outcome.playtest;
}
viewport_results.push(outcome.result);
}
let mut diagnostics = viewport_results
.iter()
.flat_map(|result| {
result
.diagnostics
.iter()
.map(move |message| format!("{}: {message}", result.viewport.file_stem()))
})
.collect::<Vec<_>>();
let playtest_passed = match (&input.playtest_scenario, &playtest) {
(None, None) => true,
(Some(_), Some(result)) => {
if !result.passed {
diagnostics.extend(
result
.diagnostics
.iter()
.map(|message| format!("playtest: {message}")),
);
}
result.passed
}
(Some(_), None) => {
diagnostics.push("playtest: desktop 试玩结果缺失".to_string());
false
}
(None, Some(_)) => {
diagnostics.push("playtest: 未请求试玩却产生了试玩结果".to_string());
false
}
};
let passed = viewport_results.iter().all(|result| result.passed) && playtest_passed;
let report_path = input.evidence_root.join("validation.json");
Ok(BrowserValidationResult {
schema_version: RESULT_SCHEMA_VERSION.to_string(),
url: preview_url.as_str().to_string(),
browser: BrowserIdentity {
kind: discovered.kind,
product: version.product,
protocol_version: version.protocol_version,
},
passed,
viewport_results,
playtest,
diagnostics,
evidence: BrowserValidationEvidencePaths {
root: input.evidence_root.clone(),
report_path,
},
completed_at_unix_ms: 0,
})
}
async fn validate_viewport(
browser: &Browser,
preview_url: &Url,
input: &BrowserValidationInput,
viewport: BrowserValidationViewport,
) -> Result<BrowserViewportValidationOutcome, String> {
let (width, height, mobile) = viewport.dimensions();
let page = browser
.new_page("about:blank")
.await
.map_err(|error| format!("创建 {} 页面失败:{error}", viewport.file_stem()))?;
page.execute(SetDeviceMetricsOverrideParams::new(
i64::from(width),
i64::from(height),
1.0,
mobile,
))
.await
.map_err(|error| format!("设置 {} 视口失败:{error}", viewport.file_stem()))?;
page.execute(SetTouchEmulationEnabledParams::new(mobile))
.await
.map_err(|error| format!("设置触摸模拟失败:{error}"))?;
page.execute(SetBypassServiceWorkerParams::new(true))
.await
.map_err(|error| format!("绕过 Service Worker 失败:{error}"))?;
page.evaluate_on_new_document(RESTRICTION_SCRIPT)
.await
.map_err(|error| format!("安装浏览器限制脚本失败:{error}"))?;
let tasks = start_capture_tasks(&page, preview_url).await?;
let navigation = tokio::time::timeout(BROWSER_TIMEOUT, page.goto(preview_url.as_str()))
.await
.map_err(|_| format!("{} 页面导航超时", viewport.file_stem()))?;
if let Err(error) = navigation {
let _ = tasks.stop().await;
let _ = page.close().await;
return Err(format!("{} 页面导航失败:{error}", viewport.file_stem()));
}
tokio::time::sleep(Duration::from_millis(input.settle_ms)).await;
let playtest = if viewport == BrowserValidationViewport::Desktop {
match input.playtest_scenario {
Some(scenario) => Some(run_desktop_playtest(&page, scenario).await),
None => None,
}
} else {
None
};
let snapshot_script = build_snapshot_script(&input.expected_text)?;
let snapshot: PageSnapshot = page
.evaluate(snapshot_script)
.await
.map_err(|error| format!("采集 {} 页面状态失败:{error}", viewport.file_stem()))?
.into_value()
.map_err(|error| format!("解析 {} 页面状态失败:{error}", viewport.file_stem()))?;
let screenshot = page
.screenshot(
ScreenshotParams::builder()
.format(CaptureScreenshotFormat::Png)
.full_page(false)
.capture_beyond_viewport(false)
.build(),
)
.await
.map_err(|error| format!("采集 {} PNG 失败:{error}", viewport.file_stem()))?;
if !screenshot.starts_with(b"\x89PNG\r\n\x1a\n") {
return Err(format!("{} 截图不是有效 PNG", viewport.file_stem()));
}
let screenshot_path = input
.evidence_root
.join(format!("{}.png", viewport.file_stem()));
write_atomic(&screenshot_path, &screenshot)?;
tokio::task::yield_now().await;
let capture = tasks.stop().await?;
let _ = page.close().await;
if !capture.infrastructure_errors.is_empty() {
return Err(format!(
"浏览器安全拦截失败:{}",
capture.infrastructure_errors.join("")
));
}
let expected_text = input
.expected_text
.iter()
.enumerate()
.map(|(index, text)| BrowserExpectedTextMatch {
text: text.clone(),
found: snapshot
.expected_text_matches
.get(index)
.copied()
.unwrap_or(false),
})
.collect::<Vec<_>>();
let mut diagnostics = Vec::new();
if snapshot.ready_state != "complete" {
diagnostics.push(format!("document.readyState={}", snapshot.ready_state));
}
let missing_text = expected_text
.iter()
.filter(|item| !item.found)
.map(|item| item.text.as_str())
.collect::<Vec<_>>();
if !missing_text.is_empty() {
diagnostics.push(format!("缺少可见文本:{}", missing_text.join("")));
}
if input.fail_on_console_error && !capture.console_errors.is_empty() {
diagnostics.push(format!("console error {}", capture.console_errors.len()));
}
if !capture.exceptions.is_empty() {
diagnostics.push(format!("未捕获异常 {}", capture.exceptions.len()));
}
let fatal_request_count = capture
.failed_requests
.iter()
.filter(|request| request.fatal)
.count();
if fatal_request_count > 0 {
diagnostics.push(format!("失败请求 {}", fatal_request_count));
}
if !same_preview_origin(&snapshot.final_url, preview_url) {
diagnostics.push("页面最终 URL 已离开当前预览 origin".to_string());
}
let canvases = snapshot
.canvases
.into_iter()
.map(BrowserCanvasSnapshot::into_evidence)
.collect::<Vec<_>>();
if let Some(diagnostic) = canvas_validation_diagnostic(&canvases) {
diagnostics.push(diagnostic.to_string());
}
Ok(BrowserViewportValidationOutcome {
result: BrowserViewportValidationResult {
viewport,
width,
height,
final_url: sanitize_url(&snapshot.final_url),
title: truncate_chars(&snapshot.title, 512),
ready_state: snapshot.ready_state,
visible_text_summary: snapshot.visible_text_summary,
visible_text_character_count: snapshot.visible_text_character_count,
dom_character_count: snapshot.dom_character_count,
expected_text,
console_errors: capture.console_errors,
console_warnings: capture.console_warnings,
exceptions: capture.exceptions,
failed_requests: capture.failed_requests,
canvases,
blocked_popup_count: snapshot.blocked_popup_count,
blocked_dialog_count: capture.blocked_dialog_count,
blocked_download_count: snapshot.blocked_download_count,
blocked_permission_count: snapshot.blocked_permission_count,
blocked_service_worker_count: snapshot.blocked_service_worker_count,
screenshot_path,
passed: diagnostics.is_empty(),
diagnostics,
},
playtest,
})
}
@@ -0,0 +1,215 @@
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use super::model::{DiscoveredBrowser, DiscoveredBrowserKind};
pub fn discover_chrome_or_edge() -> Result<DiscoveredBrowser, String> {
let mut seen = HashSet::new();
for (path, kind) in system_browser_candidates() {
if !path.is_absolute() {
continue;
}
let canonical = path.canonicalize().unwrap_or(path);
if seen.insert(canonical.clone()) && is_executable_file(&canonical) {
return Ok(DiscoveredBrowser {
kind,
executable_path: canonical,
});
}
}
Err("未发现可用的 Google Chrome、Chromium 或 Microsoft Edge".to_string())
}
pub(super) fn system_browser_candidates() -> Vec<(PathBuf, DiscoveredBrowserKind)> {
let mut candidates = Vec::new();
append_platform_candidates(&mut candidates);
candidates
}
#[cfg(target_os = "linux")]
fn append_platform_candidates(candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) {
candidates.extend([
(
PathBuf::from("/opt/google/chrome/chrome"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/opt/google/chrome/google-chrome"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/usr/bin/google-chrome-stable"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/usr/bin/google-chrome"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/usr/bin/chromium"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/usr/bin/chromium-browser"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/usr/lib/chromium/chromium"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/usr/lib/chromium-browser/chromium-browser"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/opt/microsoft/msedge/msedge"),
DiscoveredBrowserKind::Edge,
),
(
PathBuf::from("/usr/bin/microsoft-edge-stable"),
DiscoveredBrowserKind::Edge,
),
(
PathBuf::from("/usr/bin/microsoft-edge"),
DiscoveredBrowserKind::Edge,
),
]);
}
#[cfg(target_os = "macos")]
fn append_platform_candidates(candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) {
candidates.extend([
(
PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
DiscoveredBrowserKind::Chrome,
),
(
PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
DiscoveredBrowserKind::Edge,
),
]);
}
#[cfg(target_os = "windows")]
fn append_platform_candidates(candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) {
for folder_id in [
&FOLDER_ID_PROGRAM_FILES,
&FOLDER_ID_PROGRAM_FILES_X86,
&FOLDER_ID_LOCAL_APP_DATA,
] {
let Some(root) = windows_known_folder_path(folder_id) else {
continue;
};
candidates.push((
root.join("Google/Chrome/Application/chrome.exe"),
DiscoveredBrowserKind::Chrome,
));
candidates.push((
root.join("Chromium/Application/chrome.exe"),
DiscoveredBrowserKind::Chrome,
));
candidates.push((
root.join("Microsoft/Edge/Application/msedge.exe"),
DiscoveredBrowserKind::Edge,
));
}
}
#[cfg(target_os = "windows")]
#[repr(C)]
struct WindowsGuid {
data1: u32,
data2: u16,
data3: u16,
data4: [u8; 8],
}
#[cfg(target_os = "windows")]
const FOLDER_ID_PROGRAM_FILES: WindowsGuid = WindowsGuid {
data1: 0x905e63b6,
data2: 0xc1bf,
data3: 0x494e,
data4: [0xb2, 0x9c, 0x65, 0xb7, 0x32, 0xd3, 0xd2, 0x1a],
};
#[cfg(target_os = "windows")]
const FOLDER_ID_PROGRAM_FILES_X86: WindowsGuid = WindowsGuid {
data1: 0x7c5a40ef,
data2: 0xa0fb,
data3: 0x4bfc,
data4: [0x87, 0x4a, 0xc0, 0xf2, 0xe0, 0xb9, 0xfa, 0x8e],
};
#[cfg(target_os = "windows")]
const FOLDER_ID_LOCAL_APP_DATA: WindowsGuid = WindowsGuid {
data1: 0xf1b32785,
data2: 0x6fba,
data3: 0x4fcf,
data4: [0x9d, 0x55, 0x7b, 0x8e, 0x7f, 0x15, 0x70, 0x91],
};
#[cfg(target_os = "windows")]
#[link(name = "shell32")]
extern "system" {
fn SHGetKnownFolderPath(
folder_id: *const WindowsGuid,
flags: u32,
token: *mut std::ffi::c_void,
path: *mut *mut u16,
) -> i32;
}
#[cfg(target_os = "windows")]
#[link(name = "ole32")]
extern "system" {
fn CoTaskMemFree(value: *mut std::ffi::c_void);
}
#[cfg(target_os = "windows")]
fn windows_known_folder_path(folder_id: &WindowsGuid) -> Option<PathBuf> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
use std::ptr;
use std::slice;
let mut raw_path = ptr::null_mut();
let result = unsafe { SHGetKnownFolderPath(folder_id, 0, ptr::null_mut(), &mut raw_path) };
if result < 0 || raw_path.is_null() {
return None;
}
let mut length = 0;
while unsafe { *raw_path.add(length) } != 0 {
length += 1;
}
let path = PathBuf::from(OsString::from_wide(unsafe {
slice::from_raw_parts(raw_path, length)
}));
unsafe { CoTaskMemFree(raw_path.cast()) };
path.is_absolute().then_some(path)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn append_platform_candidates(_candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) {}
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
@@ -0,0 +1,99 @@
use std::fs;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tempfile::NamedTempFile;
use super::model::BrowserValidationResult;
pub(super) fn browser_validation_result_for_report(
result: &BrowserValidationResult,
) -> Result<BrowserValidationResult, String> {
let evidence_root = &result.evidence.root;
let expected_report_path = evidence_root.join("validation.json");
if result.evidence.report_path != expected_report_path {
return Err("浏览器验证报告路径与证据目录不匹配".to_string());
}
let mut persisted = result.clone();
persisted.evidence.root = PathBuf::from(".");
persisted.evidence.report_path = PathBuf::from("validation.json");
for viewport in &mut persisted.viewport_results {
let relative = viewport
.screenshot_path
.strip_prefix(evidence_root)
.map_err(|_| "浏览器验证截图路径不在证据目录内".to_string())?;
if relative.as_os_str().is_empty() || relative.components().count() != 1 {
return Err("浏览器验证截图路径不是证据目录内的直接文件".to_string());
}
viewport.screenshot_path = relative.to_path_buf();
}
Ok(persisted)
}
pub(super) fn validate_evidence_path(path: &Path) -> Result<(), String> {
if !path.is_absolute() || path.parent().is_none() {
return Err("evidenceRoot 必须是非根目录的绝对路径".to_string());
}
if path
.components()
.any(|component| matches!(component, Component::ParentDir | Component::CurDir))
{
return Err("evidenceRoot 不能包含 . 或 ..".to_string());
}
Ok(())
}
pub(super) fn prepare_evidence_root(path: &Path) -> Result<(), String> {
validate_evidence_path(path)?;
if let Ok(metadata) = fs::symlink_metadata(path) {
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err("evidenceRoot 必须是真实目录且不能是符号链接".to_string());
}
} else {
fs::create_dir_all(path)
.map_err(|error| format!("创建浏览器证据目录失败:{}: {error}", path.display()))?;
}
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取浏览器证据目录失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err("evidenceRoot 必须是真实目录且不能是符号链接".to_string());
}
Ok(())
}
pub(super) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| format!("证据文件缺少父目录:{}", path.display()))?;
let mut temporary =
NamedTempFile::new_in(parent).map_err(|error| format!("创建证据临时文件失败:{error}"))?;
temporary
.write_all(bytes)
.map_err(|error| format!("写入证据临时文件失败:{error}"))?;
temporary
.as_file()
.sync_all()
.map_err(|error| format!("同步证据临时文件失败:{error}"))?;
temporary
.persist(path)
.map_err(|error| format!("保存证据文件失败:{}: {}", path.display(), error.error))?;
Ok(())
}
pub(super) fn write_json_report(
path: &Path,
result: &BrowserValidationResult,
) -> Result<(), String> {
let bytes = serde_json::to_vec_pretty(result)
.map_err(|error| format!("序列化浏览器验证报告失败:{error}"))?;
write_atomic(path, &bytes)
}
pub(super) fn unix_time_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX)
}
@@ -0,0 +1,269 @@
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serialize};
pub(super) const RESULT_SCHEMA_VERSION: &str = "browser-validation.v1";
pub(super) const DEFAULT_SETTLE_MS: u64 = 800;
pub(super) const MAX_SETTLE_MS: u64 = 30_000;
pub(super) const MAX_EXPECTED_TEXT_ITEMS: usize = 32;
pub(super) const MAX_EXPECTED_TEXT_CHARS: usize = 512;
pub(super) const MAX_URL_CHARS: usize = 2_048;
pub(super) const BROWSER_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum BrowserValidationViewport {
Desktop,
Mobile,
}
pub(super) const REQUIRED_VIEWPORTS: [BrowserValidationViewport; 2] = [
BrowserValidationViewport::Desktop,
BrowserValidationViewport::Mobile,
];
impl BrowserValidationViewport {
pub(super) fn dimensions(self) -> (u32, u32, bool) {
match self {
Self::Desktop => (1280, 720, false),
Self::Mobile => (390, 844, true),
}
}
pub(super) fn file_stem(self) -> &'static str {
match self {
Self::Desktop => "desktop",
Self::Mobile => "mobile",
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum BrowserPlaytestScenario {
GenericV1,
LaneDefenseV1,
}
impl BrowserPlaytestScenario {
pub(super) fn as_str(self) -> &'static str {
match self {
Self::GenericV1 => "generic-v1",
Self::LaneDefenseV1 => "lane-defense-v1",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct BrowserValidationInput {
pub url: String,
#[serde(deserialize_with = "deserialize_fixed_viewports")]
pub viewports: Vec<BrowserValidationViewport>,
#[serde(default)]
pub expected_text: Vec<String>,
#[serde(default = "default_settle_ms")]
pub settle_ms: u64,
#[serde(default = "default_fail_on_console_error")]
pub fail_on_console_error: bool,
#[serde(default)]
pub playtest_scenario: Option<BrowserPlaytestScenario>,
pub evidence_root: PathBuf,
}
fn default_settle_ms() -> u64 {
DEFAULT_SETTLE_MS
}
fn default_fail_on_console_error() -> bool {
true
}
pub(super) fn validate_fixed_viewports(
viewports: &[BrowserValidationViewport],
) -> Result<(), String> {
if viewports.len() != REQUIRED_VIEWPORTS.len() {
return Err("viewports 必须且只能同时包含 desktop 和 mobile".to_string());
}
let mut unique = HashSet::new();
if viewports.iter().any(|viewport| !unique.insert(*viewport)) {
return Err("viewports 不能重复".to_string());
}
if REQUIRED_VIEWPORTS
.iter()
.any(|required| !unique.contains(required))
{
return Err("viewports 只能包含 desktop 和 mobile".to_string());
}
Ok(())
}
fn deserialize_fixed_viewports<'de, D>(
deserializer: D,
) -> Result<Vec<BrowserValidationViewport>, D::Error>
where
D: Deserializer<'de>,
{
let viewports = Vec::<BrowserValidationViewport>::deserialize(deserializer)?;
validate_fixed_viewports(&viewports).map_err(serde::de::Error::custom)?;
Ok(viewports)
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DiscoveredBrowserKind {
Chrome,
Edge,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredBrowser {
pub kind: DiscoveredBrowserKind,
pub executable_path: PathBuf,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserIdentity {
pub kind: DiscoveredBrowserKind,
pub product: String,
pub protocol_version: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserValidationEvidencePaths {
pub root: PathBuf,
pub report_path: PathBuf,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserExpectedTextMatch {
pub text: String,
pub found: bool,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum BrowserPlaytestPhase {
Ready,
Playing,
Won,
Lost,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct BrowserPlaytestAssertion {
pub name: String,
pub passed: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct BrowserPlaytestResult {
pub scenario: BrowserPlaytestScenario,
pub scenario_fingerprint: String,
pub passed: bool,
pub initial_sequence: Option<u64>,
pub initial_phase: Option<BrowserPlaytestPhase>,
pub initial_level: Option<u64>,
pub final_sequence: Option<u64>,
pub final_phase: Option<BrowserPlaytestPhase>,
pub final_level: Option<u64>,
pub assertions: Vec<BrowserPlaytestAssertion>,
pub diagnostics: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserConsoleMessage {
pub level: String,
pub text: String,
pub source_url: Option<String>,
pub line_number: Option<u32>,
pub column_number: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserException {
pub text: String,
pub source_url: Option<String>,
pub line_number: u32,
pub column_number: u32,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserFailedRequest {
pub url: String,
pub method: String,
pub resource_type: String,
pub error_text: String,
pub status_code: Option<u16>,
pub canceled: bool,
pub blocked_by_policy: bool,
pub fatal: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserCanvasProbe {
pub width: u32,
pub height: u32,
pub css_width: f64,
pub css_height: f64,
pub visible_area: f64,
pub sample_count: u32,
pub non_empty_pixel_count: u32,
pub non_empty: Option<bool>,
pub probe_error: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserViewportValidationResult {
pub viewport: BrowserValidationViewport,
pub width: u32,
pub height: u32,
pub final_url: String,
pub title: String,
pub ready_state: String,
pub visible_text_summary: String,
pub visible_text_character_count: usize,
pub dom_character_count: usize,
pub expected_text: Vec<BrowserExpectedTextMatch>,
pub console_errors: Vec<BrowserConsoleMessage>,
pub console_warnings: Vec<BrowserConsoleMessage>,
pub exceptions: Vec<BrowserException>,
pub failed_requests: Vec<BrowserFailedRequest>,
pub canvases: Vec<BrowserCanvasProbe>,
pub blocked_popup_count: u32,
pub blocked_dialog_count: u32,
pub blocked_download_count: u32,
pub blocked_permission_count: u32,
pub blocked_service_worker_count: u32,
pub screenshot_path: PathBuf,
pub passed: bool,
pub diagnostics: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserValidationResult {
pub schema_version: String,
pub url: String,
pub browser: BrowserIdentity,
pub passed: bool,
pub viewport_results: Vec<BrowserViewportValidationResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub playtest: Option<BrowserPlaytestResult>,
pub diagnostics: Vec<String>,
pub evidence: BrowserValidationEvidencePaths,
pub completed_at_unix_ms: u64,
}
@@ -0,0 +1,143 @@
use std::net::Ipv4Addr;
use chromiumoxide::cdp::browser_protocol::network::ResourceType;
use url::{Host, Url};
use super::evidence::validate_evidence_path;
use super::model::{
validate_fixed_viewports, BrowserValidationInput, MAX_EXPECTED_TEXT_CHARS,
MAX_EXPECTED_TEXT_ITEMS, MAX_SETTLE_MS, MAX_URL_CHARS,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum PreviewRequestBlockReason {
CrossOrigin,
RedirectTarget,
WebSocketBeforeHandshake,
}
impl PreviewRequestBlockReason {
pub(super) fn message(self) -> &'static str {
match self {
Self::CrossOrigin => "blocked by preview origin policy before request",
Self::RedirectTarget => "blocked cross-origin redirect before request",
Self::WebSocketBeforeHandshake => "blocked cross-origin WebSocket before handshake",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum PreviewRequestDecision {
Allow,
Block(PreviewRequestBlockReason),
}
pub(super) fn validate_input(input: &BrowserValidationInput) -> Result<Url, String> {
if input.url.chars().count() > MAX_URL_CHARS {
return Err("预览 URL 过长".to_string());
}
let url = Url::parse(input.url.trim()).map_err(|error| format!("预览 URL 无效:{error}"))?;
if url.scheme() != "http"
|| url.host() != Some(Host::Ipv4(Ipv4Addr::LOCALHOST))
|| url.port().is_none()
|| url.port() == Some(0)
|| !url.username().is_empty()
|| url.password().is_some()
|| url.fragment().is_some()
{
return Err("只允许带显式端口的 http://127.0.0.1 预览 URL".to_string());
}
validate_fixed_viewports(&input.viewports)?;
if input.settle_ms > MAX_SETTLE_MS {
return Err(format!("settleMs 不能超过 {MAX_SETTLE_MS}"));
}
if input.expected_text.len() > MAX_EXPECTED_TEXT_ITEMS {
return Err(format!(
"expectedText 不能超过 {MAX_EXPECTED_TEXT_ITEMS}"
));
}
for text in &input.expected_text {
let length = text.chars().count();
if text.trim().is_empty() || length > MAX_EXPECTED_TEXT_CHARS {
return Err(format!(
"expectedText 每项必须非空且不超过 {MAX_EXPECTED_TEXT_CHARS} 字符"
));
}
}
validate_evidence_path(&input.evidence_root)?;
Ok(url)
}
pub(super) fn preview_proxy_bypass_list(origin: &Url) -> String {
let (Some(host), Some(port)) = (origin.host_str(), origin.port()) else {
return "<-loopback>".to_string();
};
format!("<-loopback>;http://{host}:{port};ws://{host}:{port}")
}
pub(super) fn preview_request_decision(
raw: &str,
resource_type: &ResourceType,
redirected: bool,
origin: &Url,
) -> PreviewRequestDecision {
let allowed = if resource_type == &ResourceType::WebSocket {
websocket_url_allowed(raw, origin)
} else {
request_url_allowed(raw, origin)
};
if allowed {
PreviewRequestDecision::Allow
} else if resource_type == &ResourceType::WebSocket {
PreviewRequestDecision::Block(PreviewRequestBlockReason::WebSocketBeforeHandshake)
} else if redirected {
PreviewRequestDecision::Block(PreviewRequestBlockReason::RedirectTarget)
} else {
PreviewRequestDecision::Block(PreviewRequestBlockReason::CrossOrigin)
}
}
fn request_url_allowed(raw: &str, origin: &Url) -> bool {
if raw == "about:blank" || raw.starts_with("data:") {
return true;
}
if let Some(inner) = raw.strip_prefix("blob:") {
return Url::parse(inner)
.ok()
.map(|url| same_origin_url(&url, origin))
.unwrap_or(false);
}
let Ok(url) = Url::parse(raw) else {
return false;
};
match url.scheme() {
"http" => same_origin_url(&url, origin),
_ => false,
}
}
fn websocket_url_allowed(raw: &str, origin: &Url) -> bool {
let Ok(url) = Url::parse(raw) else {
return false;
};
origin.scheme() == "http"
&& url.scheme() == "ws"
&& url.host() == origin.host()
&& url.port_or_known_default() == origin.port_or_known_default()
&& url.username().is_empty()
&& url.password().is_none()
&& url.fragment().is_none()
}
pub(super) fn same_preview_origin(raw: &str, origin: &Url) -> bool {
Url::parse(raw)
.ok()
.map(|url| same_origin_url(&url, origin))
.unwrap_or(false)
}
fn same_origin_url(left: &Url, right: &Url) -> bool {
left.scheme() == right.scheme()
&& left.host() == right.host()
&& left.port_or_known_default() == right.port_or_known_default()
}
@@ -0,0 +1,99 @@
use chromiumoxide::Page;
use tokio::time::Instant;
use super::{
click_playtest_control, poll_playable_web_game_state, BrowserPlaytestPhase,
BrowserPlaytestResult, BrowserPlaytestScenario, PlayableWebGameState,
PLAYTEST_RESTART_SELECTOR, PLAYTEST_START_SELECTOR,
};
pub(super) async fn execute_generic_playtest(
page: &Page,
deadline: Instant,
result: &mut BrowserPlaytestResult,
initial: PlayableWebGameState,
) -> Result<(), String> {
click_playtest_control(page, PLAYTEST_START_SELECTOR, "start", deadline).await?;
result.set_assertion("start-control-clicked", true);
let started = poll_playable_web_game_state(
page,
BrowserPlaytestScenario::GenericV1,
deadline,
initial.sequence,
"start",
|state| {
matches!(
state.phase,
BrowserPlaytestPhase::Playing | BrowserPlaytestPhase::Won
)
},
)
.await?;
if let Some(state) = started.last_state.as_ref() {
result.record_final_state(state);
}
let start_sequence_advanced = started
.last_state
.as_ref()
.map(|state| state.sequence > initial.sequence)
.unwrap_or(false);
let start_phase_valid = started
.last_state
.as_ref()
.map(|state| {
matches!(
state.phase,
BrowserPlaytestPhase::Playing | BrowserPlaytestPhase::Won
)
})
.unwrap_or(false);
result.set_assertion("start-sequence-advanced", start_sequence_advanced);
result.set_assertion("start-phase-playing-or-won", start_phase_valid);
if !started.matched {
return Err("generic-v1 start 后状态未在总时限内推进".to_string());
}
let started_state = started
.last_state
.ok_or_else(|| "generic-v1 start 后未读取到状态".to_string())?;
click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?;
result.set_assertion("restart-control-clicked", true);
let restarted = poll_playable_web_game_state(
page,
BrowserPlaytestScenario::GenericV1,
deadline,
started_state.sequence,
"restart",
|state| {
matches!(
state.phase,
BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing
)
},
)
.await?;
if let Some(state) = restarted.last_state.as_ref() {
result.record_final_state(state);
}
let restart_sequence_advanced = restarted
.last_state
.as_ref()
.map(|state| state.sequence > started_state.sequence)
.unwrap_or(false);
let restart_phase_valid = restarted
.last_state
.as_ref()
.map(|state| {
matches!(
state.phase,
BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing
)
})
.unwrap_or(false);
result.set_assertion("restart-sequence-advanced", restart_sequence_advanced);
result.set_assertion("restart-phase-ready-or-playing", restart_phase_valid);
if !restarted.matched {
return Err("generic-v1 restart 后状态未在总时限内推进".to_string());
}
Ok(())
}
@@ -0,0 +1,445 @@
use std::collections::{HashMap, HashSet};
use chromiumoxide::Page;
use tokio::time::Instant;
use super::{
click_playtest_control, poll_playable_web_game_state, required_playable_finite_number,
validate_lane_value, BrowserPlaytestPhase, BrowserPlaytestResult, BrowserPlaytestScenario,
PlayableEnemyState, PlayableWebGameState, MAX_PLAYABLE_GAME_COLLECTION_ITEMS,
MAX_PLAYABLE_GAME_ID_CHARS, PLAYTEST_DEFENDER_OPTION_SELECTOR, PLAYTEST_LANE_CELL_SELECTOR,
PLAYTEST_NEXT_LEVEL_SELECTOR, PLAYTEST_RESTART_SELECTOR, PLAYTEST_SPEED_UP_SELECTOR,
PLAYTEST_START_SELECTOR,
};
pub(in crate::browser) struct LaneBattleProgress {
pub(in crate::browser) baseline_sequence: u64,
pub(in crate::browser) previous_state: PlayableWebGameState,
pub(in crate::browser) sequence_advanced: bool,
pub(in crate::browser) sequence_monotonic: bool,
pub(in crate::browser) enemy_position_changed: bool,
pub(in crate::browser) enemy_health_decreased: bool,
}
impl LaneBattleProgress {
pub(in crate::browser) fn new(initial: PlayableWebGameState) -> Self {
Self {
baseline_sequence: initial.sequence,
previous_state: initial,
sequence_advanced: false,
sequence_monotonic: true,
enemy_position_changed: false,
enemy_health_decreased: false,
}
}
pub(in crate::browser) fn observe(&mut self, state: &PlayableWebGameState) {
self.sequence_advanced |= state.sequence > self.baseline_sequence;
self.sequence_monotonic &= state.sequence >= self.previous_state.sequence;
let (position_changed, health_decreased) =
lane_enemy_state_changes(&self.previous_state, state);
self.enemy_position_changed |= position_changed;
self.enemy_health_decreased |= health_decreased;
self.previous_state = state.clone();
}
pub(in crate::browser) fn completed(&self, state: &PlayableWebGameState) -> bool {
self.sequence_advanced
&& self.sequence_monotonic
&& self.enemy_position_changed
&& self.enemy_health_decreased
&& state.phase == BrowserPlaytestPhase::Won
}
}
pub(super) fn parse_lane_defense_playable_state(
object: &serde_json::Map<String, serde_json::Value>,
) -> Result<
(
Option<String>,
Option<usize>,
Option<Vec<PlayableEnemyState>>,
),
String,
> {
let selected_defender_id = match object.get("selectedDefenderId") {
Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(value))
if !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS =>
{
Some(value.clone())
}
Some(_) => {
return Err(
"lane-defense 状态 selectedDefenderId 必须是 null 或非空字符串".to_string(),
);
}
None => return Err("lane-defense 状态缺少 selectedDefenderId".to_string()),
};
let defenders = object
.get("defenders")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "lane-defense 状态 defenders 必须是数组".to_string())?;
if defenders.len() > MAX_PLAYABLE_GAME_COLLECTION_ITEMS {
return Err("lane-defense 状态 defenders 超过数量上限".to_string());
}
let enemy_values = object
.get("enemies")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "lane-defense 状态 enemies 必须是数组".to_string())?;
if enemy_values.len() > MAX_PLAYABLE_GAME_COLLECTION_ITEMS {
return Err("lane-defense 状态 enemies 超过数量上限".to_string());
}
let mut enemy_ids = HashSet::with_capacity(enemy_values.len());
let mut enemies = Vec::with_capacity(enemy_values.len());
for enemy_value in enemy_values {
let enemy = enemy_value
.as_object()
.ok_or_else(|| "lane-defense 状态 enemy 必须是 object".to_string())?;
let id = enemy
.get("id")
.and_then(serde_json::Value::as_str)
.filter(|value| {
!value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS
})
.ok_or_else(|| "lane-defense 状态 enemy.id 必须是有界非空字符串".to_string())?;
if !enemy_ids.insert(id.to_string()) {
return Err("lane-defense 状态 enemy.id 不能重复".to_string());
}
validate_lane_value(
enemy
.get("lane")
.ok_or_else(|| "lane-defense 状态 enemy 缺少 lane".to_string())?,
)?;
let position = required_playable_finite_number(enemy, "position")?;
let health = required_playable_finite_number(enemy, "health")?;
let max_health = required_playable_finite_number(enemy, "maxHealth")?;
if health < 0.0 || max_health <= 0.0 || health > max_health {
return Err("lane-defense 状态 enemy health/maxHealth 边界无效".to_string());
}
enemies.push(PlayableEnemyState {
id: id.to_string(),
position,
health,
});
}
Ok((selected_defender_id, Some(defenders.len()), Some(enemies)))
}
pub(super) async fn execute_lane_defense_playtest(
page: &Page,
deadline: Instant,
result: &mut BrowserPlaytestResult,
initial: PlayableWebGameState,
) -> Result<(), String> {
let initial_phase_ready = initial.phase == BrowserPlaytestPhase::Ready;
let level_positive = initial.level > 0;
result.set_assertion("initial-phase-ready", initial_phase_ready);
result.set_assertion("level-positive", level_positive);
if !initial_phase_ready {
return Err("lane-defense-v1 初始状态必须为 ready".to_string());
}
if !level_positive {
return Err("lane-defense-v1 初始 level 必须大于 0".to_string());
}
click_playtest_control(page, PLAYTEST_START_SELECTOR, "start", deadline).await?;
result.set_assertion("start-control-visible", true);
result.set_assertion("start-control-enabled", true);
result.set_assertion("start-control-clicked", true);
let started = poll_playable_web_game_state(
page,
BrowserPlaytestScenario::LaneDefenseV1,
deadline,
initial.sequence,
"start",
|state| state.phase == BrowserPlaytestPhase::Playing,
)
.await?;
if let Some(state) = started.last_state.as_ref() {
result.record_final_state(state);
}
let start_sequence_advanced = started
.last_state
.as_ref()
.map(|state| state.sequence > initial.sequence)
.unwrap_or(false);
result.set_assertion("start-sequence-advanced", start_sequence_advanced);
result.set_assertion("start-phase-playing", started.matched);
if !started.matched {
return Err("lane-defense-v1 start 后 sequence 未推进或未进入 playing".to_string());
}
let started_state = started
.last_state
.ok_or_else(|| "lane-defense-v1 start 后未读取到状态".to_string())?;
click_playtest_control(
page,
PLAYTEST_DEFENDER_OPTION_SELECTOR,
"defender-option",
deadline,
)
.await?;
result.set_assertion("defender-option-control-visible", true);
result.set_assertion("defender-option-control-enabled", true);
result.set_assertion("defender-option-control-clicked", true);
let selected = poll_playable_web_game_state(
page,
BrowserPlaytestScenario::LaneDefenseV1,
deadline,
started_state.sequence,
"defender-option",
|state| state.selected_defender_id.is_some(),
)
.await?;
if let Some(state) = selected.last_state.as_ref() {
result.record_final_state(state);
}
let defender_selection_sequence_advanced = selected
.last_state
.as_ref()
.map(|state| state.sequence > started_state.sequence)
.unwrap_or(false);
result.set_assertion(
"defender-selection-sequence-advanced",
defender_selection_sequence_advanced,
);
result.set_assertion("defender-selection-recorded", selected.matched);
if !selected.matched {
return Err(
"lane-defense-v1 defender-option 后 sequence 未推进或未记录选择状态".to_string(),
);
}
let selected_state = selected
.last_state
.ok_or_else(|| "lane-defense-v1 选择后未读取到状态".to_string())?;
let defender_count_before_placement = selected_state
.defender_count
.ok_or_else(|| "lane-defense-v1 defenders 状态缺失".to_string())?;
click_playtest_control(page, PLAYTEST_LANE_CELL_SELECTOR, "lane-cell", deadline).await?;
result.set_assertion("lane-cell-control-visible", true);
result.set_assertion("lane-cell-control-enabled", true);
result.set_assertion("lane-cell-control-clicked", true);
let placed = poll_playable_web_game_state(
page,
BrowserPlaytestScenario::LaneDefenseV1,
deadline,
selected_state.sequence,
"lane-cell",
|state| {
state
.defender_count
.map(|count| count > defender_count_before_placement)
.unwrap_or(false)
},
)
.await?;
if let Some(state) = placed.last_state.as_ref() {
result.record_final_state(state);
}
let defender_placement_sequence_advanced = placed
.last_state
.as_ref()
.map(|state| state.sequence > selected_state.sequence)
.unwrap_or(false);
result.set_assertion(
"defender-placement-sequence-advanced",
defender_placement_sequence_advanced,
);
result.set_assertion("defender-count-increased", placed.matched);
if !placed.matched {
return Err(
"lane-defense-v1 lane-cell 后 sequence 未推进或 defender 数量未增加".to_string(),
);
}
let mut combat_state = placed
.last_state
.ok_or_else(|| "lane-defense-v1 放置后未读取到状态".to_string())?;
let mut enemies_present = combat_state
.enemies
.as_ref()
.map(|enemies| !enemies.is_empty())
.unwrap_or(false);
if !enemies_present {
let enemies_ready = poll_playable_web_game_state(
page,
BrowserPlaytestScenario::LaneDefenseV1,
deadline,
combat_state.sequence,
"enemy-spawn",
|state| {
state
.enemies
.as_ref()
.map(|enemies| !enemies.is_empty())
.unwrap_or(false)
},
)
.await?;
if let Some(state) = enemies_ready.last_state.as_ref() {
result.record_final_state(state);
}
enemies_present = enemies_ready.matched;
if let Some(state) = enemies_ready.last_state {
combat_state = state;
}
}
result.set_assertion("enemies-present-after-placement", enemies_present);
if !enemies_present {
return Err("lane-defense-v1 放置后没有可观察 enemy".to_string());
}
click_playtest_control(page, PLAYTEST_SPEED_UP_SELECTOR, "speed-up", deadline).await?;
result.set_assertion("speed-up-control-visible", true);
result.set_assertion("speed-up-control-enabled", true);
result.set_assertion("speed-up-control-clicked", true);
let battle_baseline_sequence = combat_state.sequence;
let mut battle_progress = LaneBattleProgress::new(combat_state);
let completed = poll_playable_web_game_state(
page,
BrowserPlaytestScenario::LaneDefenseV1,
deadline,
battle_baseline_sequence,
"speed-up/battle",
|state| {
battle_progress.observe(state);
battle_progress.completed(state)
},
)
.await?;
if let Some(state) = completed.last_state.as_ref() {
result.record_final_state(state);
}
let won = completed
.last_state
.as_ref()
.map(|state| state.phase == BrowserPlaytestPhase::Won)
.unwrap_or(false);
result.set_assertion(
"battle-sequence-advanced",
battle_progress.sequence_advanced,
);
result.set_assertion(
"battle-sequence-monotonic",
battle_progress.sequence_monotonic,
);
result.set_assertion(
"enemy-position-changed",
battle_progress.enemy_position_changed,
);
result.set_assertion(
"enemy-health-decreased",
battle_progress.enemy_health_decreased,
);
result.set_assertion("phase-won", won);
if !completed.matched {
return Err("lane-defense-v1 未在总时限内观察到战斗推进并获胜".to_string());
}
let won_state = completed
.last_state
.ok_or_else(|| "lane-defense-v1 获胜后未读取到状态".to_string())?;
click_playtest_control(page, PLAYTEST_NEXT_LEVEL_SELECTOR, "next-level", deadline).await?;
result.set_assertion("next-level-control-visible", true);
result.set_assertion("next-level-control-enabled", true);
result.set_assertion("next-level-control-clicked", true);
let next_level = poll_playable_web_game_state(
page,
BrowserPlaytestScenario::LaneDefenseV1,
deadline,
won_state.sequence,
"next-level",
|state| state.level > won_state.level,
)
.await?;
if let Some(state) = next_level.last_state.as_ref() {
result.record_final_state(state);
}
let next_level_sequence_advanced = next_level
.last_state
.as_ref()
.map(|state| state.sequence > won_state.sequence)
.unwrap_or(false);
result.set_assertion("next-level-sequence-advanced", next_level_sequence_advanced);
result.set_assertion("level-increased", next_level.matched);
if !next_level.matched {
return Err("lane-defense-v1 next-level 后 sequence 未推进或 level 未增加".to_string());
}
let next_level_state = next_level
.last_state
.ok_or_else(|| "lane-defense-v1 next-level 后未读取到状态".to_string())?;
click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?;
result.set_assertion("restart-control-visible", true);
result.set_assertion("restart-control-enabled", true);
result.set_assertion("restart-control-clicked", true);
let restarted = poll_playable_web_game_state(
page,
BrowserPlaytestScenario::LaneDefenseV1,
deadline,
next_level_state.sequence,
"restart",
|state| {
matches!(
state.phase,
BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing
)
},
)
.await?;
if let Some(state) = restarted.last_state.as_ref() {
result.record_final_state(state);
}
let restart_sequence_advanced = restarted
.last_state
.as_ref()
.map(|state| state.sequence > next_level_state.sequence)
.unwrap_or(false);
let restart_phase_valid = restarted
.last_state
.as_ref()
.map(|state| {
matches!(
state.phase,
BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing
)
})
.unwrap_or(false);
result.set_assertion("restart-sequence-advanced", restart_sequence_advanced);
result.set_assertion("restart-phase-ready-or-playing", restart_phase_valid);
if !restarted.matched {
return Err("lane-defense-v1 restart 后状态未在总时限内推进".to_string());
}
Ok(())
}
pub(in crate::browser) fn lane_enemy_state_changes(
previous: &PlayableWebGameState,
current: &PlayableWebGameState,
) -> (bool, bool) {
let Some(previous_enemies) = previous.enemies.as_ref() else {
return (false, false);
};
let Some(current_enemies) = current.enemies.as_ref() else {
return (false, false);
};
let current_by_id = current_enemies
.iter()
.map(|enemy| (enemy.id.as_str(), enemy))
.collect::<HashMap<_, _>>();
let mut position_changed = false;
let mut health_decreased = false;
for previous_enemy in previous_enemies {
match current_by_id.get(previous_enemy.id.as_str()) {
Some(enemy) => {
position_changed |= enemy.position != previous_enemy.position;
health_decreased |= enemy.health < previous_enemy.health;
}
None => {
health_decreased |= previous_enemy.health > 0.0;
}
}
}
(position_changed, health_decreased)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use chromiumoxide::browser::{Browser, BrowserConfig};
use futures::StreamExt;
use tempfile::{Builder as TempDirBuilder, TempDir};
use super::cdp::run_browser_validation;
use super::discovery::discover_chrome_or_edge;
use super::evidence::{
browser_validation_result_for_report, prepare_evidence_root, unix_time_ms, write_json_report,
};
use super::model::{BrowserValidationInput, BrowserValidationResult, BROWSER_TIMEOUT};
use super::network_policy::{preview_proxy_bypass_list, validate_input};
fn browser_process_temp_root() -> PathBuf {
#[cfg(unix)]
{
PathBuf::from("/tmp")
}
#[cfg(not(unix))]
{
std::env::temp_dir()
}
}
pub(super) fn create_browser_process_temp_dir() -> Result<TempDir, String> {
TempDirBuilder::new()
.prefix("ga-browser-")
.tempdir_in(browser_process_temp_root())
.map_err(|error| format!("创建浏览器临时目录失败:{error}"))
}
pub async fn validate_local_preview_in_browser(
input: BrowserValidationInput,
) -> Result<BrowserValidationResult, String> {
let preview_url = validate_input(&input)?;
prepare_evidence_root(&input.evidence_root)?;
let browser_executable = discover_chrome_or_edge()?;
let browser_temp = create_browser_process_temp_dir()?;
let profile_path = browser_temp.path().join("profile");
fs::create_dir(&profile_path)
.map_err(|error| format!("创建浏览器临时 Profile 失败:{error}"))?;
let browser_temp_path = browser_temp.path().to_string_lossy().into_owned();
let proxy_bypass_list = preview_proxy_bypass_list(&preview_url);
let config = BrowserConfig::builder()
.chrome_executable(&browser_executable.executable_path)
.user_data_dir(profile_path)
.env("TMPDIR", browser_temp_path)
.new_headless_mode()
.enable_request_intercept()
.disable_cache()
.disable_https_first()
.request_timeout(BROWSER_TIMEOUT)
.launch_timeout(BROWSER_TIMEOUT)
.window_size(1280, 720)
.arg(("proxy-server", "http://127.0.0.1:9"))
.arg(("proxy-bypass-list", proxy_bypass_list.as_str()))
.arg("block-new-web-contents")
.arg("deny-permission-prompts")
.arg("disable-notifications")
.arg("disable-service-worker")
.build()
.map_err(|error| format!("构建浏览器配置失败:{error}"))?;
let (mut browser, mut handler) = tokio::time::timeout(BROWSER_TIMEOUT, Browser::launch(config))
.await
.map_err(|_| "启动浏览器超时".to_string())?
.map_err(|error| format!("启动浏览器失败:{error}"))?;
let handler_task = tokio::spawn(async move {
while let Some(message) = handler.next().await {
if message.is_err() {
break;
}
}
});
let validation =
run_browser_validation(&browser, &browser_executable, &preview_url, &input).await;
let close_result = browser
.close()
.await
.map_err(|error| format!("关闭浏览器失败:{error}"));
let wait_result = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await;
handler_task.abort();
let _ = handler_task.await;
drop(browser_temp);
let mut result = validation?;
close_result?;
match wait_result {
Ok(Ok(_)) => {}
Ok(Err(error)) => return Err(format!("等待浏览器退出失败:{error}")),
Err(_) => return Err("等待浏览器退出超时".to_string()),
}
result.completed_at_unix_ms = unix_time_ms();
let persisted_result = browser_validation_result_for_report(&result)?;
write_json_report(&result.evidence.report_path, &persisted_result)?;
Ok(result)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,148 @@
use super::*;
pub(super) fn print_swarm_mcp_status<W: Write>(root: &Path, output: &mut W) -> Result<(), String> {
match read_external_agent_runner_mcp_catalog(root) {
Ok(catalog) => print_swarm_mcp_catalog(&catalog, output),
Err(error) => writeln!(output, "[MCP] 状态读取失败:{error}")
.map_err(|write_error| format!("写入终端失败:{write_error}")),
}
}
pub(super) fn print_swarm_mcp_catalog<W: Write>(
catalog: &GameCreatorMcpCatalog,
output: &mut W,
) -> Result<(), String> {
writeln!(
output,
"[MCP] catalog={} servers={} tools={}",
catalog.fingerprint.chars().take(12).collect::<String>(),
catalog.servers.len(),
catalog.tools.len(),
)
.map_err(|error| format!("写入终端失败:{error}"))?;
for server in &catalog.servers {
writeln!(
output,
" server={} transport={} enabled={} connected={} required={} tools={}{}",
server.server_id,
server.transport,
server.enabled,
server.connected,
server.required,
server.tool_count,
server
.error
.as_deref()
.map(|error| format!(" error={error}"))
.unwrap_or_default(),
)
.map_err(|error| format!("写入终端失败:{error}"))?;
}
for tool in &catalog.tools {
let description = sanitize_prompt_context(&tool.description)
.chars()
.take(180)
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
writeln!(
output,
" tool={}/{} approval={} readOnly={} schema={}{}",
tool.server_id,
tool.name,
tool.effective_approval_mode,
tool.read_only_hint,
serde_json::to_string(&tool.input_schema)
.unwrap_or_else(|_| "{}".to_string())
.chars()
.take(600)
.collect::<String>(),
if description.is_empty() {
String::new()
} else {
format!(" description={description}")
},
)
.map_err(|error| format!("写入终端失败:{error}"))?;
}
Ok(())
}
pub(super) fn print_swarm_agents<W: Write>(root: &Path, output: &mut W) -> Result<(), String> {
writeln!(output, "静态 Agent").map_err(|error| format!("写入终端失败:{error}"))?;
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
for role in group.roles {
writeln!(
output,
"- {} / {}: {} ({})",
group.label, role.role, role.task_id, role.id
)
.map_err(|error| format!("写入终端失败:{error}"))?;
}
}
let dynamic = read_game_creator_agent_runtimes_at(root)?
.into_iter()
.filter(|runtime| runtime.state.agent_id.starts_with("child-"))
.collect::<Vec<_>>();
if !dynamic.is_empty() {
writeln!(output, "动态隔离 Agent").map_err(|error| format!("写入终端失败:{error}"))?;
for runtime in dynamic {
writeln!(
output,
"- {} <- {} run={} delegation={} status={}/{}",
runtime.state.agent_id,
runtime
.state
.parent_agent_id
.as_deref()
.unwrap_or("unknown"),
runtime.state.run_id,
runtime.state.delegation_id.as_deref().unwrap_or("unknown"),
runtime.state.status,
runtime.state.phase
)
.map_err(|error| format!("写入终端失败:{error}"))?;
}
}
Ok(())
}
pub(super) fn print_swarm_status<W: Write>(root: &Path, output: &mut W) -> Result<(), String> {
let runtimes = read_game_creator_agent_runtimes_at(root)?;
for runtime in runtimes.iter().filter(|runtime| {
!runtime.state.run_id.is_empty()
|| runtime.task_queue.pending > 0
|| runtime.task_queue.running > 0
|| runtime.task_queue.waiting_for_confirmation > 0
|| runtime.task_queue.waiting_for_user_input > 0
}) {
print_runtime_state(&runtime.state, &runtime.task_queue, output)?;
print_runtime_response_stream_status(runtime.response_stream.as_ref(), output)?;
}
if runtimes
.iter()
.all(|runtime| runtime.state.run_id.is_empty())
{
writeln!(output, "当前没有 Agent Runtime 记录。")
.map_err(|error| format!("写入终端失败:{error}"))?;
}
Ok(())
}
pub(super) fn print_runtime_response_stream_status<W: Write>(
stream: Option<&AgentRuntimeResponseStream>,
output: &mut W,
) -> Result<(), String> {
let Some(stream) = stream else {
return Ok(());
};
writeln!(
output,
"[回复流] status={} sequence={} chars={}",
stream.status,
stream.sequence,
stream.accumulated_text.chars().count()
)
.map_err(|error| format!("写入终端失败:{error}"))
}

Some files were not shown because too many files have changed in this diff Show More