并行拆分客户端生成与验收大型模块
拆分 tool-plan 持久交接的模型、校验、发现和跨平台存储模块。 拆分旧生成链的提示、画布、产物、生命周期和追踪模块。 拆分 Swarm CLI 的输入、观察、终态判定、报告与测试模块。 拆分浏览器发现、CDP、网络策略、试玩、证据与测试模块。 补充兼容可见性、集成验证和第三轮拆分文档。
This commit is contained in:
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
Reference in New Issue
Block a user