diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 7ca25e36f..0d306cb21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -11,8 +11,8 @@ const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; -const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; -const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力使用说明:所有操作都通过 `agc_cocos_execute` 注入一个 JavaScript 函数体,并在需要时 `return await Editor.Message.request(...)` 返回 JSON。已核对的 Creator 3.8.8 操作包括:读取场景树 `Editor.Message.request('scene', 'query-node-tree')`;读取节点 `Editor.Message.request('scene', 'query-node', uuid)`;修改节点或组件属性 `Editor.Message.request('scene', 'set-property', { uuid, path, value })`;添加组件 `Editor.Message.request('scene', 'create-component', { uuid, component })`;调用编辑器场景脚本 `Editor.Message.request('scene', 'execute-scene-script', { name, method, args })`;查询资源 `Editor.Message.request('asset-db', 'query-asset-info', uuid)` / `query-url`。先用只读查询拿到真实 uuid 和当前状态,再执行单个修改并回读校验;需要多个修改时逐步执行,不猜 uuid、组件 cid 或属性路径。"#; +const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:能力来自客户端随包提供的内置插件 agc-cocos-editor。当前 agc_tools 直接提供 cocos_* 操作工具,并保留 agc_cocos_execute 通用 JavaScript 入口。识别 Cocos 项目后先检查当前工具列表;缺失时报告客户端内置插件状态。不要搜索、读取、安装或启用项目目录中的 extensions、MCP 包、package.json 插件声明,也不要要求用户打开第三方 MCP 面板。历史对话中的旧插件说明不能覆盖当前工具目录和此边界。"#; +const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 15622b1fb..adb8f0979 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2345,6 +2345,15 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str) #[cfg(all(windows, feature = "cocos-editor-execute"))] async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { + bridge_cocos_call(state, arguments, None).await +} + +#[cfg(all(windows, feature = "cocos-editor-execute"))] +async fn bridge_cocos_call( + state: &DirectToolBridgeState, + arguments: &Value, + operation: Option<&str>, +) -> Value { if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return bridge_tool_result( "Cocos Creator 插件已禁用,agc_cocos_execute 不可用".to_string(), @@ -2353,8 +2362,21 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) ); } let prepared = (|| { - bridge_reject_unknown_fields(arguments, &["code"])?; enforce_project_permission_policy(&state.root, "cocos.editor.execute")?; + if let Some(operation) = operation { + let tool = cocos_editor_bridge::cocos_operation_catalog() + .iter() + .find(|tool| tool["name"] == operation) + .ok_or_else(|| "未知 Cocos 操作".to_string())?; + let validator = jsonschema::validator_for(&tool["inputSchema"]) + .map_err(|e| format!("Cocos schema 错误:{e}"))?; + if let Err(error) = validator.validate(arguments) { + return Err(format!("Cocos 参数无效:{error}")); + } + return cocos_editor_bridge::build_cocos_operation_code(operation, arguments) + .map_err(|e| e.to_string()); + } + bridge_reject_unknown_fields(arguments, &["code"])?; let code = arguments .get("code") .and_then(Value::as_str) @@ -2372,7 +2394,11 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) ) } }; - let mut uncertain = state.cocos_execute_uncertain.lock().await; + let Ok(mut uncertain) = state.cocos_execute_uncertain.try_lock() else { + return bridge_tool_result( + json!({"status":"failed","retryAllowed":false,"message":"已有 Cocos 操作执行中,请等待回执"}).to_string(), + Vec::new(), true); + }; if *uncertain { return bridge_tool_result( json!({ @@ -2385,6 +2411,11 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) ); } let root = state.root.clone(); + let timeout_ms = if operation.is_some() { + 60_000 + } else { + cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS + }; let result = tokio::task::spawn_blocking(move || { // Cocos execute talks to the already-open Creator process through its // validated Inspector/pipe bridge. It does not mutate AGC's project @@ -2398,23 +2429,63 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) cocos_editor_bridge::execute_cocos_editor_code_for_project( root.to_string_lossy().as_ref(), &code, - cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS, + timeout_ms, ) }) .await; match result { Ok(Ok(response)) => { - let is_error = !response.ok; + let mut result = response.result; + let status = if operation.is_some() { + result + .as_ref() + .and_then(|v| v["status"].as_str()) + .unwrap_or(if response.ok { "completed" } else { "failed" }) + .to_string() + } else if response.ok { + "completed".to_string() + } else { + "failed".to_string() + }; + let is_error = !response.ok || status != "completed"; + if status == "needs-reconciliation" { + *uncertain = true; + } + // 截图以 MCP image block 返回,不能被文本截断破坏 base64。 + let mut images = Vec::new(); + if operation == Some("cocos_preview_debug_capture") { + if let Some(value) = result + .as_mut() + .and_then(|v| v.get_mut("result")) + .and_then(Value::as_object_mut) + { + if let Some(image) = value.remove("__image") { + if image["mimeType"] == "image/png" { + if let Some(data) = image["data"].as_str() { + images.push(data.to_string()); + } + } + } + } + } let text = json!({ - "status": if response.ok { "completed" } else { "failed" }, + "status": status, "requestId": response.request_id, - "result": response.result, + "result": result, "error": response.error, }) .to_string(); bridge_tool_result( - redact_agent_runtime_project_paths(&state.root, &text, 32_000), - Vec::new(), + redact_agent_runtime_project_paths( + &state.root, + &text, + if operation.is_some() { + 2 * 1024 * 1024 + } else { + 32_000 + }, + ), + images, is_error, ) } @@ -2468,6 +2539,10 @@ async fn handle_direct_tool_bridge( "agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments), #[cfg(all(windows, feature = "cocos-editor-execute"))] "agc_cocos_execute" => bridge_cocos_execute(&state, &request.arguments).await, + #[cfg(all(windows, feature = "cocos-editor-execute"))] + operation if cocos_editor_bridge::is_cocos_operation(operation) => { + bridge_cocos_call(&state, &request.arguments, Some(operation)).await + } "agc_write_file" => { bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 02f180582..419b668e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -468,6 +468,11 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "additionalProperties": false } })); + tools.extend( + cocos_editor_bridge::cocos_operation_catalog() + .iter() + .cloned(), + ); } if controlled_web_search { tools.push(json!({ @@ -1650,6 +1655,10 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< "agc_write_file" => call_agc_write_file(&arguments).await, #[cfg(all(windows, feature = "cocos-editor-execute"))] "agc_cocos_execute" => call_agc_cocos_execute(&arguments).await, + #[cfg(all(windows, feature = "cocos-editor-execute"))] + operation if cocos_editor_bridge::is_cocos_operation(operation) => { + call_client_tool_bridge(operation, &arguments).await + } "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, "agc_generate_image" => call_agc_generate_image(&arguments).await, "agc_edit_image" => call_agc_edit_image(&arguments).await, @@ -1863,6 +1872,17 @@ mod tests { .any(|tool| tool["name"] == "agc_cocos_execute"), expected == "true" ); + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .filter(|tool| tool["name"] + .as_str() + .is_some_and(cocos_editor_bridge::is_cocos_operation)) + .count(), + if expected == "true" { 36 } else { 0 } + ); } #[cfg(all(windows, feature = "cocos-editor-execute"))] @@ -1905,6 +1925,16 @@ mod tests { enabled ); if !enabled { + for tool in cocos_editor_bridge::cocos_operation_catalog() { + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + bridge.url().to_string(), + call_client_tool_bridge(tool["name"].as_str().unwrap(), &json!({})), + ) + .await; + assert_eq!(response["isError"], true); + assert!(response.to_string().contains("插件已禁用")); + } let response = EXTERNAL_MCP_BRIDGE_URL .scope( bridge.url().to_string(), @@ -1957,6 +1987,114 @@ mod tests { } use std::io::{Read, Write}; + #[cfg(all(windows, feature = "cocos-editor-execute"))] + #[tokio::test] + #[ignore = "显式指定已打开的自有 Cocos smoke 工程后运行"] + async fn cocos_real_tools_list_and_call() { + let _guard = crate::builtin_plugins::test_lock(); + let root = + PathBuf::from(std::env::var("AGC_COCOS_TEST_PROJECT").expect("explicit smoke root")) + .canonicalize() + .unwrap(); + let package: Value = + serde_json::from_slice(&std::fs::read(root.join("package.json")).unwrap()).unwrap(); + assert_eq!( + package["name"], "agc-cocos-capability-smoke", + "只操作自有测试工程" + ); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + std::fs::create_dir_all(root.join(".agent")).unwrap(); + if !root.join(".agent/manifest.json").exists() { + std::fs::write(root.join(".agent/manifest.json"), "{}").unwrap(); + } + let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(&root, false) + .await + .unwrap(); + let tools = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + assert_eq!( + tools["tools"] + .as_array() + .unwrap() + .iter() + .filter(|t| t["name"] + .as_str() + .is_some_and(cocos_editor_bridge::is_cocos_operation)) + .count(), + 36 + ); + async fn invoke(root: &Path, url: &str, name: &str, args: Value) -> Value { + let reply = EXTERNAL_MCP_BRIDGE_URL.scope(url.to_string(), handle_direct_tools_mcp_request(root, json!({ + "jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":name,"arguments":args} + }))).await.unwrap(); + assert_eq!(reply["result"]["isError"], false, "{name}: {reply}"); + reply["result"].clone() + } + let ping = invoke(&root, bridge.url(), "cocos_ping", json!({})).await; + assert!(ping.to_string().contains("ready")); + let hierarchy = invoke(&root, bridge.url(), "cocos_get_hierarchy", json!({})).await; + let parsed: Value = + serde_json::from_str(hierarchy["content"][0]["text"].as_str().unwrap()).unwrap(); + let tree = &parsed["result"]["result"]["tree"]; + let canvas = tree["children"] + .as_array() + .unwrap() + .iter() + .find(|n| n["name"] == "Canvas") + .unwrap(); + invoke( + &root, + bridge.url(), + "cocos_create_ui_label", + json!({ + "parentNid":canvas["uuid"], "name":"AGC_MCP_SMOKE", "text":"MCP", "save":false + }), + ) + .await; + invoke(&root, bridge.url(), "cocos_mcp_undo_last", json!({})).await; + if let Ok(url) = std::env::var("AGC_COCOS_TEST_PREVIEW_URL") { + invoke( + &root, + bridge.url(), + "cocos_preview_debug_start", + json!({"url":url}), + ) + .await; + let capture = invoke( + &root, + bridge.url(), + "cocos_preview_debug_capture", + json!({}), + ) + .await; + assert!(capture["content"] + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "image" && b["mimeType"] == "image/png")); + invoke(&root, bridge.url(), "cocos_preview_debug_stop", json!({})).await; + } + let final_tree = invoke(&root, bridge.url(), "cocos_get_hierarchy", json!({})).await; + assert!(!final_tree.to_string().contains("AGC_MCP_SMOKE")); + } + + #[test] + fn cocos_mcp_schemas_accept_real_arguments_and_reject_unknown_fields() { + for tool in cocos_editor_bridge::cocos_operation_catalog() { + let validator = jsonschema::validator_for(&tool["inputSchema"]).unwrap(); + assert!(!validator.is_valid(&json!({"unregistered-field":1}))); + } + let ui = cocos_editor_bridge::cocos_operation_catalog() + .iter() + .find(|t| t["name"] == "cocos_apply_ui_spec") + .unwrap(); + let validator = jsonschema::validator_for(&ui["inputSchema"]).unwrap(); + assert!(validator.is_valid(&json!({"parentNid":1,"nodes":[{"kind":"container","children":[{"kind":"label","text":"测试"}]}],"save":false}))); + assert!(!validator.is_valid(&json!({"parentNid":1,"nodes":[{"kind":"shell-command"}]}))); + } + #[tokio::test] async fn loopback_tool_bridge_client_omits_agc_marker() { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback fixture"); @@ -2061,6 +2199,17 @@ mod tests { .chain( cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute") ) + .chain( + cocos_editor_bridge::cocos_operation_catalog() + .iter() + .filter_map(|tool| { + if cfg!(all(windows, feature = "cocos-editor-execute")) { + tool["name"].as_str() + } else { + None + } + }) + ) .collect::>() ); let serialized = specs.to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs index e30fb0268..5e34469f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -247,7 +247,13 @@ pub(crate) fn cocos_editor_agent_tool_available() -> bool { pub(crate) fn available_agent_tools() -> Vec<&'static str> { if cocos_editor_agent_tool_available() { - vec![AGC_COCOS_EDITOR_TOOL_NAME] + let mut tools = vec![AGC_COCOS_EDITOR_TOOL_NAME]; + tools.extend( + cocos_editor_bridge::cocos_operation_catalog() + .iter() + .filter_map(|tool| tool["name"].as_str()), + ); + tools } else { Vec::new() } diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 0db1f1dc9..5513aa6e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -2079,7 +2079,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p loop { let registered = host.list().expect("list").into_iter().any(|plugin| { plugin.id == "agc-cocos-editor" - && plugin.commands.len() == 1 + && plugin.commands.len() == 2 && plugin.capabilities.len() == 1 && plugin.panels.len() == 1 }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1843e98f5..83c222450 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,14 @@ # 踩坑与排障记录 +## 2026-09-13 Cocos 操作必须核对实际回执与引擎就绪状态 + +- named pipe 使用真实换行分帧;测试客户端若写入字面量反斜杠 n,服务端不会执行请求。不能仅凭这类超时推断 Scene WebView 卡死,更不能重放不确定写操作。 +- Scene WebView 可直接运行内置 JS;使用 `require('cc')` 完整模块,旧全局 `cc` 并不包含所有构造器(例如 UITransform)。 +- AssetDB reimport 返回时 SpriteFrame 可能仍不可加载;先有界预加载,再开始事务。首次场景保存使用 AssetDB 创建、等待导入、标记快照已保存和官方 open-scene,不对未命名场景调用会弹窗的 save-scene。 +- 独立预览 BrowserWindow 先加载 about:blank 建立 renderer,再启用 CDP;截止时间必须覆盖初始化和导航全部步骤,失败只关闭自有窗口。 +- 工具注册、JS 正常返回和真实编辑成功是三种不同证据。UI 必须创建组件/持久资源并回读,不能把空节点或固定 verified:true 当成完成。 +- 桌面测试指定 `cargo test --bin genarrative-ai-game-creator-shell`,避免默认多目标构建尝试覆盖正在运行的客户端 EXE;Windows 临时目录 owner/DACL 失败单独报告,不当成本次功能回归。 + ## 2026-09-12 Cocos 请求不得回退到项目内 MCP 扩展 AGC 的 Cocos 能力来自随客户端分发的 `agc-cocos-editor` 内置插件,工具名为 `cocos.editor.execute` / `agc_cocos_execute`。Cocos 项目中的 `extensions/`、`package.json` 插件声明和第三方 MCP 包不是桥接来源;内置工具不可用时必须报告客户端插件状态,不能扫描、安装、启用或要求用户打开项目内 MCP 面板。 diff --git a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md index 53ddf1a17..6dd227d24 100644 --- a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md +++ b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md @@ -1,5 +1,15 @@ # AGC Cocos Creator 编辑器桥接模块 +## 2026-09-13 内置操作目录实施合同 + +- 交付:在插件包内实现 36 个 `cocos_*` 操作,以同一份 JSON Schema 目录供插件宿主和 DirectProject `agc_tools` 使用;保留通用 execute。目录与实现一起随客户端编译分发,不依赖项目扩展或开发机目录。 +- 实现:`src/operations/` 保存操作目录、主进程编排、场景运行时和预览运行时;JS 入口与 native crate 复用同一代码构造器。DirectProject 在现有执行权限和结果不确定阻断之下调用,不引入第二套连接或项目写锁。 +- 身份:场景查询返回真实 UUID 和本次 Creator 场景会话内的 NID;场景切换后旧 NID 不复用。默认写操作先校验,再读取结果;只有确实完成回读才返回 verified。 +- 事务:普通节点/组件操作和批量 UI 保存变更前后的场景序列化状态,失败恢复变更前状态;MCP 撤销只接受场景当前状态仍等于对应操作的后状态,避免覆盖用户后续修改。编辑器 undo 仍走官方撤销。 +- UI:支持 64 个节点、12 层、Label/Sprite/Button/持久 Shape、Layout/Widget/九宫格,超限在写入前拒绝;首次场景保存可指定 assets 下新路径。模板资产由 AssetDB 导入,禁止手工写场景 JSON。 +- 预览:插件托管独立 Electron BrowserWindow,收集控制台/JS 异常和网络失败、截图并关闭自有窗口;仅允许当前项目本地预览地址,不附着或终止用户浏览器。 +- 验收:插件行为测试、native 目录/构造器一致性测试、DirectProject tools/list 与 tools/call 开关测试、真实 Creator 查询/创建/组件/UI/回滚/撤销/保存/预览验证分开报告。桌面集成测试只构建测试目标,避免覆盖运行中的客户端 EXE。 + ## 目标 用户打开 Cocos Creator 项目后,AGC 在正确的 Creator 主进程内安装随包 JavaScript bootstrap;用户无需手动安装项目扩展。核心位于 `plugins/agc-cocos-editor/native/cocos-editor-bridge`,通过 Node Inspector 引导、通过 named pipe 执行业务代码。 @@ -42,7 +52,7 @@ cocos-editor-bridge = { path = ".../plugins/agc-cocos-editor/native/cocos-editor AGC 不再内置 Cocos 专属 Tauri 命令。适配器 `cocos-editor` 由插件包 `plugins/agc-cocos-editor` 提供,实现通用 `EditorAdapter`(`prepare` / `inject` / `ping` / `status` / `execute` / `detect` / `connect` / `disconnect`),由宿主按 manifest 的 `adapter` 字段注册;插件入口通过 `host.rpc` 触发这些操作,宿主校验 `editor.rpc` 权限后路由到 native 模块。 -Runtime 只广告 `cocos.editor.execute`,代码输入使用当前项目根,目标 PID 由 crate 内部唯一匹配;具体权限沿用已有 Runtime 策略。Windows 的标准 dev/release 脚本统一默认选择 `cocos-editor-execute`,它包含 `windows-bootstrap`;显式 feature 参数优先。纯 Cargo 默认 feature 仍为空。进程发现只用于内部目标校验,不建立客户端扫描服务,不向模型公开 PID、端口或 Inspector。 +插件宿主提供 `cocos.editor.execute` 和 `cocos.editor.operation`;DirectProject 的 `agc_tools` 从插件目录注册全部 36 个独立 `cocos_*` 工具,保留 `agc_cocos_execute`。两条入口使用同一份 `src/operations/catalog.json` 和 JS 源码,native crate 以 include_str 编译进客户端。版本指纹由源码与构造模板共同决定,JS/Rust 构造器测试保证一致。目标 PID 由当前项目唯一匹配;权限检查、禁用开关和不确定结果阻断共用现有链路。执行中的第二个操作会立即被拒绝,不积压稍后发送的写请求。 ## 插件包形态 @@ -57,7 +67,7 @@ plugins/agc-cocos-editor/ 宿主按 `AGC_PLUGIN_WORKSPACE`、随包 `/plugins`、开发构建仓库 `plugins/` 的顺序解析工作区;插件包内的 `native/payload` 由构建脚本随包映射,生成的 DLL 不入库。插件协议、权限和面板挂载全部复用通用宿主,Cocos 专属逻辑只存在于本插件包:进程名与 `--project` 解析、Creator 版本校验、named pipe 协议和 Windows 注入。 -该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。禁用后插件进程停止且不能启动,Runtime 工具 `cocos.editor.execute` 与 DirectProject 的 `agc_cocos_execute` 同时从工具目录、工具策略快照和 Agent 上下文里消失;重新启用后立即恢复。开关状态保存在 AppData `extensions/builtin-plugins.json`。 +该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。禁用后插件进程停止且不能启动,通用 execute 和全部 `cocos_*` 工具从 Agent 目录消失;直接请求隐藏工具也会在宿主执行前被拒绝。开关状态保存在 AppData `extensions/builtin-plugins.json`,隔离 MCP 每次 tools/list 都向绑定宿主询问当前状态。 ## AGC 项目打开入口 @@ -70,7 +80,11 @@ Cocos Creator 项目,并将其标记为 `cocos` 项目类型。选择目录后 ## 第一阶段命令协议 -DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` feature 注册 `agc_cocos_execute`,参数只有 `code`。客户端在 blocking worker 内调用插件 native 模块,执行前检查现有项目权限但不获取 `.agent/project.lock`;该入口只通过 Inspector / pipe 操作已打开的 Creator,文件写入工具仍独立使用项目锁。当前 bridge 出现执行结果不确定后拒绝后续 execute。旧 Runtime 的对应工具名为 `cocos.editor.execute`,继续使用它已有的 pending action、权限和恢复语义;插件入口注册的同名命令走宿主 `host.rpc` → `EditorAdapter` 路径,两条路径共享同一 native 实现和不确定结果阻断语义。 +DirectProject 与插件宿主都构造受控 JS 并调用 native executor。通用 execute 维持原期限,目录操作期限为 60 秒,涵盖资源导入和预览加载。客户端在 blocking worker 内执行,检查项目权限但不获取 `.agent/project.lock`。截图提取为 MCP image block,不能截断 base64;失败、回滚或需要核对不能因底层 JS 正常返回而被改写为 completed。 + +场景运行时只定位绑定 Creator 中唯一 `packages://scene/` WebView,使用 `require('cc')` 完整模块及当前场景管理器,不安装或读取项目扩展。事务保存前后序列化快照;MCP 撤销只在当前状态与事务后状态相同时恢复,已保存事务同步写回。对用户后续手动修改不做覆盖;场景切换和 Creator 重启不保留历史。 + +首次保存将官方序列化结果交给 AssetDB 创建新资源,等待导入可查询后标记原快照已保存,再通过官方 open-scene 读取该资源;不直接更改 UUID,不对未命名场景调用会弹对话框的 save-scene。UI 先等待 SpriteFrame 可加载,再开始场景事务;模板 PNG 保留在 assets/agc-ui-shapes 供后续复用。 注入 payload 在目标 Creator 主进程内监听 `\\.\pipe\genarrative-cocos-editor-{pid}`,使用换行分隔的 JSON。crate 只生成三种操作: @@ -80,7 +94,7 @@ DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` {"schemaVersion":"game-creator-cocos-editor-bridge.v1","requestId":"cocos-42-...","processId":42,"projectPath":"C:\\demo","command":{"op":"execute","code":"return Editor.Project.path"}} ``` -回执必须回传相同的 `schemaVersion/requestId/processId`。`execute.code` 是支持 `await` 和 `return` 的 JavaScript 函数体,上限为 128 KiB,单次回执上限为 2 MiB;bootstrap 通过 `AsyncFunction('Editor', 'require', code)` 在 Creator 主进程事件循环串行执行,并返回可序列化 JSON。它使用 Creator/Node 的现有权限,不是代码沙箱;AGC 不维护额外的 Cocos 业务 API 名单。 +回执必须回传相同的 `schemaVersion/requestId/processId`。`execute.code` 是支持 `await` 和 `return` 的 JavaScript 函数体,上限为 128 KiB,单次回执上限为 2 MiB;bootstrap 通过 `AsyncFunction('Editor', 'require', code)` 在 Creator 主进程事件循环串行执行,并返回可序列化 JSON。它使用 Creator/Node 的现有权限,不是代码沙箱;目录操作按自身 schema 验证参数,通用 execute 仍允许自定义 JS。 Rust 客户端在写入前通过 `GetNamedPipeServerProcessId` 验证 pipe 属于目标 PID,读写使用 overlapped I/O 和 deadline。execute 开始写入后遇到断线、超时或无可信回执,返回 `ExecutionUncertain`,Runtime 进入 `needs-reconciliation`,不得自动重放。客户端超时不等于 JavaScript 已取消,bootstrap 保持同一串行队列直到原执行结束;同步死循环仍可能阻塞 Creator,需要真实集成阶段提供运行时中断方案。 diff --git a/plugins/agc-cocos-editor/README.md b/plugins/agc-cocos-editor/README.md index 06fd900ea..859255264 100644 --- a/plugins/agc-cocos-editor/README.md +++ b/plugins/agc-cocos-editor/README.md @@ -4,7 +4,7 @@ Cocos Creator 编辑器桥接插件。用户侧看到的是一个普通 AGC 插 RPC、权限和能力注册全部由通用宿主负责,只有“如何连接 Cocos Creator”属于本插件。 它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。用户在运行时设置里只能切换 -“是否可用”,禁用后插件不能启动,`cocos.editor.execute` / `agc_cocos_execute` 也会 +“是否可用”,禁用后插件不能启动,`agc_cocos_execute` 与全部 `cocos_*` Agent 工具也会 从 Agent 工具列表、工具策略快照和上下文里消失;重新启用后立即恢复。 Cocos Creator 项目目录中的 `extensions/`、`package.json` 插件声明或第三方 MCP 包不属于 @@ -15,6 +15,8 @@ AGC Cocos 桥接来源。Agent 处理 Cocos 请求时只使用客户端登记的 plugin.json Agent Plugins 清单 + AGC Runtime 扩展 src/entry.mjs 运行时入口(注册命令 / 能力 / 面板,转发 host.rpc) src/cocos-editor-adapter.mjs 通用请求 → Cocos 适配器请求的翻译与入参校验 +src/operations/ 36 个操作的 schema、主进程/场景/预览实现 +src/cocos-editor-operations.mjs 插件侧构造器,与 native 编译内置代码一致 panels/cocos-editor.html 自包含面板 native/cocos-editor-bridge/ 插件自带 native 模块(进程发现、pipe 协议、注入) ``` @@ -26,6 +28,8 @@ native/cocos-editor-bridge/ 插件自带 native 模块(进程发现、pip | 协议 | `agc.plugin.v1`(stdio 行分隔 JSON-RPC 2.0) | | 适配器 id | `cocos-editor` | | 命令 | `cocos.editor.execute`(`{ code }`) | +| 操作命令 | `cocos.editor.operation`(`{ operation: "cocos_*", args: {...} }`) | +| Agent MCP | `agc_cocos_execute` + `catalog.json` 的全部 36 个 `cocos_*` 工具 | | 能力 | `cocos.editor.connection`(`{ operation, processId?, projectPath?, timeoutMs? }`) | | 面板 | `cocos-editor`(`panels/cocos-editor.html`,sidebar) | @@ -38,11 +42,53 @@ native/cocos-editor-bridge/ 插件自带 native 模块(进程发现、pip Agent 通过 `agc_cocos_execute` 注入 JavaScript 函数体;插件只负责把代码安全地送进 已校验的 Creator 主进程,不在项目目录写入扩展。常用的 Creator 3.8.8 操作如下: +客户端还注册了 `cocos.editor.operation` 操作命令。它把第三方 MCP 的 36 个稳定操作名 +暴露给宿主;DirectProject 的 `agc_tools` 同时从该目录注册 36 个独立工具。两者使用相同 +schema 和内置 JS,并在执行时走同一个受控 `editor.execute` 通道: + +```text +cocos_ping / cocos_get_capabilities / cocos_get_project_info +cocos_get_current_scene_meta / cocos_get_log_tail / cocos_diagnose +cocos_get_build_diagnostics / cocos_list_assets / cocos_get_prefab_info +cocos_get_hierarchy / cocos_search_nodes / cocos_inspect_node +cocos_set_node_active / cocos_set_node_transform / cocos_set_node_name +cocos_create_node / cocos_delete_node / cocos_reparent_node +cocos_set_node_sibling_index / cocos_duplicate_node / cocos_instantiate_prefab +cocos_set_component_property / cocos_add_component / cocos_remove_component +cocos_create_ui_shape / cocos_create_ui_label / cocos_create_ui_sprite +cocos_create_ui_button / cocos_apply_ui_spec +cocos_preview_debug_start / cocos_preview_debug_read / cocos_preview_debug_capture +cocos_preview_debug_stop / cocos_save_scene / cocos_editor_undo / cocos_mcp_undo_last +``` + +场景操作由主进程定位唯一 Scene WebView,将随包 JS 加载到该运行时,调用 Creator 场景管理器 +与 `require('cc')` 的引擎 API;保存和资源导入使用官方 `Editor.Message`。不安装项目扩展。 + +`cocos_get_hierarchy` 返回 `{ sceneUuid, tree, truncated }`,节点含 `nid/uuid`;写工具的 +`nid/parentNid/childNid` 均支持查询得到的 NID 或 UUID。`cocos_inspect_node` 返回带真实索引的 +组件属性。场景切换后旧 NID 不复用,需重新查询。 + +批量 UI 支持 container/shape/label/sprite/button、Layout、Widget、九宫格,限制为 64 个节点和 +12 层(按钮文字计入节点数)。Shape 使用 AssetDB 导入的 `assets/agc-ui-shapes/` PNG 模板; +它是可复用资产缓存,撤销节点不会删除模板。`save` 缺省 true,运行态回读和保存失败都会明确报告。 +首次保存缺省创建 `assets/Main.scene`,也可通过 `cocos_save_scene { path }` 指定新路径。 + +编辑事务保留前后序列化快照(单份 2 MiB、最多 32 次、合计 16 MiB)。MCP 撤销只在当前场景仍 +等于对应后状态时恢复,已保存事务的撤销同步保存。代码更新、Creator 重启和切换场景不保留撤销历史。 +`cocos_editor_undo` 使用官方 undo;最近操作属于本插件时额外核对恢复结果。 + +预览在插件自有、无 Node 权限的独立 Chromium 窗口运行,只接收当前 Creator 项目预览端口的 +loopback URL。采集 console、JS 异常、网络失败及 HTTP 错误,截图作为 MCP PNG image 返回; +stop 只关闭该窗口。日志只读项目 `temp/logs/`、`temp/builder/`、`build/` 下的日志文件。 + +所有操作仍受项目身份校验、单执行队列和不确定结果禁止重放约束。实现按 Creator 3.8.8 核验; +其它 3.x 版本中缺少对应运行时 API 时会报告错误,不虚报能力已执行。 + | 目的 | 示例 | | --- | --- | | 读取场景树 | `return await Editor.Message.request('scene', 'query-node-tree');` | | 读取节点 | `return await Editor.Message.request('scene', 'query-node', uuid);` | -| 修改属性 | `return await Editor.Message.request('scene', 'set-property', { uuid, path, value });` | +| 修改属性 | 优先 `cocos_set_component_property`;原始 `scene/set-property` 需要从 query-node 克隆并修改属性 dump | | 添加组件 | `return await Editor.Message.request('scene', 'create-component', { uuid, component });` | | 执行场景脚本 | `return await Editor.Message.request('scene', 'execute-scene-script', { name, method, args });` | | 查询资源 | `return await Editor.Message.request('asset-db', 'query-asset-info', uuid);` | @@ -63,7 +109,7 @@ execute 不接受并发积压。结果不确定时返回 `needs-reconciliation` `projectPath`。 - 之后宿主设置项目时推送 `project.changed` 事件,payload 带 `projectPath`。 -插件不缓存凭据、不扫描文件系统;所有编辑器操作都经 `host.rpc` 交给 native 适配器, +插件不缓存凭据;资源与日志只在当前受控项目内有界读取。编辑器操作经 `host.rpc` 交给 native 适配器, 由适配器做 PID / 项目 / 版本校验。 ## Native 模块 @@ -87,7 +133,7 @@ AGC 客户端当前在编译期链接本 crate(Cargo path 依赖),由通 ```bash cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml --features windows-bootstrap -- --include-ignored -node --test plugins/agc-cocos-editor/src/entry.test.mjs +npm test --prefix plugins/agc-cocos-editor ``` 真实 Creator 验收(注入、Inspector 引导、非空场景读写)仍按 diff --git a/plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml b/plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml index 8364e5f98..6532971e0 100644 --- a/plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml +++ b/plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml @@ -28,7 +28,6 @@ windows-transport = [ ] windows-injection = [ "windows-transport", - "dep:sha2", "windows-sys/Win32_System_Diagnostics_Debug", "windows-sys/Win32_System_LibraryLoader", "windows-sys/Win32_System_Memory", @@ -48,7 +47,7 @@ windows-bootstrap = [ editor-adapter-api = { path = "../../../../server-rs/crates/editor-adapter-api" } serde = { version = "1", features = ["derive"] } serde_json = "1" -sha2 = { version = "0.10", optional = true } +sha2 = "0.10" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", optional = true, default-features = false } diff --git a/plugins/agc-cocos-editor/native/cocos-editor-bridge/build.rs b/plugins/agc-cocos-editor/native/cocos-editor-bridge/build.rs index 2193ad70e..d2e7d45a8 100644 --- a/plugins/agc-cocos-editor/native/cocos-editor-bridge/build.rs +++ b/plugins/agc-cocos-editor/native/cocos-editor-bridge/build.rs @@ -1,6 +1,7 @@ fn main() { println!("cargo:rerun-if-changed=native/native_payload.cpp"); println!("cargo:rerun-if-changed=payload/bootstrap.cjs"); + println!("cargo:rerun-if-changed=../../src/operations"); if std::env::var_os("CARGO_CFG_WINDOWS").is_some() && std::env::var_os("CARGO_FEATURE_WINDOWS_INJECTION").is_some() { diff --git a/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/lib.rs b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/lib.rs index be517bae3..6cb63d779 100644 --- a/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/lib.rs +++ b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/lib.rs @@ -26,8 +26,10 @@ use sha2::{Digest, Sha256}; mod adapter; #[cfg(all(windows, feature = "windows-bootstrap"))] mod inspector; +mod operations; pub use adapter::{CocosEditorAdapter, COCOS_EDITOR_ADAPTER_ID, COCOS_EDITOR_RPC_METHODS}; +pub use operations::{build_cocos_operation_code, cocos_operation_catalog, is_cocos_operation}; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; diff --git a/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/operations.rs b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/operations.rs new file mode 100644 index 000000000..5368dbf0e --- /dev/null +++ b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/operations.rs @@ -0,0 +1,138 @@ +//! 插件宿主与 DirectProject 共用的能力目录、JS 实现和代码构造器。 +use crate::{validate_execute_code, BridgeError}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::sync::OnceLock; + +const CATALOG: &str = include_str!("../../../src/operations/catalog.json"); +const TEMPLATE: &str = include_str!("../../../src/operations/invoke.js.txt"); +const SOURCES: &[(&str, &str)] = &[ + ( + "assets.cjs", + include_str!("../../../src/operations/assets.cjs"), + ), + ("main.cjs", include_str!("../../../src/operations/main.cjs")), + ( + "paths.cjs", + include_str!("../../../src/operations/paths.cjs"), + ), + ( + "preview.cjs", + include_str!("../../../src/operations/preview.cjs"), + ), + ( + "scene.cjs", + include_str!("../../../src/operations/scene.cjs"), + ), + ( + "validate.cjs", + include_str!("../../../src/operations/validate.cjs"), + ), +]; + +pub fn cocos_operation_catalog() -> &'static Vec { + static CACHED: OnceLock> = OnceLock::new(); + CACHED.get_or_init(|| serde_json::from_str(CATALOG).expect("bundled Cocos catalog")) +} + +pub fn is_cocos_operation(name: &str) -> bool { + cocos_operation_catalog() + .iter() + .any(|tool| tool["name"] == name) +} + +pub fn build_cocos_operation_code(operation: &str, args: &Value) -> Result { + let tool = cocos_operation_catalog() + .iter() + .find(|tool| tool["name"] == operation) + .ok_or_else(|| BridgeError::InvalidInput("未知 Cocos 操作".into()))?; + if !args.is_object() || args.to_string().len() > 64 * 1024 { + return Err(BridgeError::InvalidInput( + "Cocos 参数必须是 64 KiB 以内的 JSON 对象".into(), + )); + } + let sources = + serde_json::to_string(&SOURCES.iter().copied().collect::>()).unwrap(); + let version = format!( + "{:x}", + Sha256::new() + .chain_update(&sources) + .chain_update(TEMPLATE) + .finalize() + ); + let names: Vec<_> = cocos_operation_catalog() + .iter() + .map(|tool| &tool["name"]) + .collect(); + // 用户参数最后插入;参数中的模板标记只能作为普通字符串,不再参与替换。 + let code = TEMPLATE + .replace("__AGC_SOURCES__", &sources) + .replace("__AGC_VERSION__", &serde_json::to_string(&version).unwrap()) + .replace("__AGC_SCHEMA__", &tool["inputSchema"].to_string()) + .replace("__AGC_NAMES__", &serde_json::to_string(&names).unwrap()) + .replace( + "__AGC_OPERATION__", + &serde_json::to_string(operation).unwrap(), + ) + .replace("__AGC_ARGS__", &args.to_string()); + validate_execute_code(&code)?; + Ok(code) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn operation_catalog_is_complete_unique_and_closed() { + let tools = cocos_operation_catalog(); + assert_eq!(tools.len(), 36); + let names: std::collections::BTreeSet<_> = + tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert_eq!(names.len(), tools.len()); + for tool in tools { + assert_eq!(tool["inputSchema"]["additionalProperties"], false); + assert!(build_cocos_operation_code( + tool["name"].as_str().unwrap(), + &serde_json::json!({}) + ) + .is_ok()); + } + assert!(build_cocos_operation_code("cocos_unknown", &serde_json::json!({})).is_err()); + assert!(build_cocos_operation_code("cocos_ping", &Value::Null).is_err()); + } + + #[test] + fn operation_builder_matches_plugin_and_preserves_literal_markers() { + use std::io::Write; + use std::process::{Command, Stdio}; + let args = serde_json::json!({"nid":1,"name":"中文 __AGC_SOURCES__ \" \\\n"}); + let expected = build_cocos_operation_code("cocos_set_node_name", &args).unwrap(); + let mut node = Command::new("node") + .current_dir(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")) + .args(["--input-type=module", "-e", "import fs from 'node:fs';import {buildCocosOperationCode} from './src/cocos-editor-operations.mjs';process.stdout.write(buildCocosOperationCode('cocos_set_node_name',JSON.parse(fs.readFileSync(0,'utf8'))));"]) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + node.stdin + .take() + .unwrap() + .write_all(args.to_string().as_bytes()) + .unwrap(); + let result = node.wait_with_output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + // JSON 对象 key 的顺序不影响执行;版本必须完全相同,参数须保留字面量。 + let actual = String::from_utf8(result.stdout).unwrap(); + let version = |code: &str| { + code.lines() + .find(|line| line.starts_with("const version")) + .unwrap() + .to_owned() + }; + assert_eq!(version(&actual), version(&expected)); + assert!(actual.contains("__AGC_SOURCES__")); + assert!(expected.contains("__AGC_SOURCES__")); + } +} diff --git a/plugins/agc-cocos-editor/package.json b/plugins/agc-cocos-editor/package.json index 05bb39822..26d9de98c 100644 --- a/plugins/agc-cocos-editor/package.json +++ b/plugins/agc-cocos-editor/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "AGC Cocos Creator 编辑器插件", "scripts": { - "test": "node --test src/entry.test.mjs" + "test": "node --test src/entry.test.mjs src/operations/operations.test.cjs" }, "dependencies": { "@genarrative/agc-plugin-sdk": "0.1.0" diff --git a/plugins/agc-cocos-editor/scripts/smoke-creator-operations.mjs b/plugins/agc-cocos-editor/scripts/smoke-creator-operations.mjs new file mode 100644 index 000000000..dc9e10c5c --- /dev/null +++ b/plugins/agc-cocos-editor/scripts/smoke-creator-operations.mjs @@ -0,0 +1,254 @@ +// 对显式指定的自有测试工程执行真实操作;先用 native creator_smoke 建立桥接。 +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; + +import { + buildCocosOperationCode, + COCOS_EDITOR_OPERATIONS, +} from '../src/cocos-editor-operations.mjs'; + +const project = fs.realpathSync(process.argv[2] || ''); +const pid = Number(process.argv[3]); +assert.ok(Number.isInteger(pid) && pid > 0, '需要显式指定 Creator PID'); +assert.equal( + JSON.parse(fs.readFileSync(path.join(project, 'package.json'), 'utf8')).name, + 'agc-cocos-capability-smoke', + '只允许自有测试工程,不操作用户项目', +); +const report = []; +const reportPath = path.join(project, 'temp/agc-operation-smoke.json'); +fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + +async function raw(code) { + const request = { + schemaVersion: 'game-creator-cocos-editor-bridge.v1', + requestId: randomUUID(), + processId: pid, + projectPath: project, + command: { op: 'execute', code }, + }; + return await new Promise((resolve, reject) => { + const socket = net.connect('\\\\.\\pipe\\genarrative-cocos-editor-' + pid); + let output = ''; + socket.setTimeout(60000); + socket.on('connect', () => socket.write(JSON.stringify(request) + '\n')); + socket.on('data', (data) => { + output += data.toString(); + if (!output.includes('\n')) return; + socket.destroy(); + const response = JSON.parse(output.split('\n')[0]); + assert.equal(response.requestId, request.requestId); + resolve(response); + }); + socket.on('error', reject); + socket.on('timeout', () => { + socket.destroy(); + reject(new Error('结果不确定:禁止自动重放')); + }); + socket.on('end', () => { + if (!output.includes('\n')) reject(new Error('回执不完整')); + }); + }); +} +async function op(name, args = {}) { + const start = Date.now(); + const response = await raw(buildCocosOperationCode(name, args)); + const copy = structuredClone(response); + if (copy.result?.result?.__image) copy.result.result.__image.data = '[PNG]'; + report.push({ name, elapsedMs: Date.now() - start, response: copy }); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + assert.equal(response.ok, true, response.error); + assert.equal( + response.result.status, + 'completed', + JSON.stringify(response.result), + ); + return response.result.result ?? response.result; +} +async function evalScene(source) { + const result = await raw( + "return await require('electron').webContents.getAllWebContents().find(w=>w.getURL().startsWith('packages://scene/')).executeJavaScript(" + + JSON.stringify(source) + + ');', + ); + assert.equal(result.ok, true, result.error); + return result.result; +} + +await op('cocos_ping'); +await op('cocos_get_capabilities'); +await op('cocos_get_project_info'); +const meta = await op('cocos_get_current_scene_meta'); +assert.ok(meta.named && !meta.dirty, '测试要求已保存且无未保存修改的 2D 场景'); +const mainUuid = meta.sceneAssetUuid; +const tree = await op('cocos_get_hierarchy'); +const canvas = tree.tree.children.find((node) => node.name === 'Canvas'); +assert.ok(canvas, '测试工程需要 Canvas'); +const parent = canvas.uuid; +const baseline = await evalScene( + 'cce.SceneFacadeManager.getCurrentFacade()._sceneProxy.serialize(true)', +); +const node = await op('cocos_create_node', { + parentNid: parent, + name: 'AGC_TestNode', +}); +await op('cocos_set_node_name', { nid: node.nid, name: 'AGC_Renamed' }); +await op('cocos_set_node_transform', { + nid: node.nid, + position: { x: 12, y: 34, z: 0 }, + rotationEuler: { x: 0, y: 0, z: 15 }, + scale: { x: 2, y: 2, z: 1 }, +}); +await op('cocos_set_node_active', { nid: node.nid, active: false }); +const component = await op('cocos_add_component', { + nid: node.nid, + componentType: 'Label', +}); +await op('cocos_set_component_property', { + nid: node.nid, + componentIndex: component.componentIndex, + property: 'string', + value: '测试', +}); +await op('cocos_remove_component', { + nid: node.nid, + componentIndex: component.componentIndex, + expectedComponentType: 'Label', +}); +await op('cocos_mcp_undo_last'); +const clone = await op('cocos_duplicate_node', { nid: node.nid }); +await op('cocos_reparent_node', { childNid: clone.nid, parentNid: node.nid }); +await op('cocos_set_node_sibling_index', { nid: node.nid, index: 0 }); +await op('cocos_search_nodes', { + nameSubstring: 'AGC_Renamed', + componentFilter: 'Label', +}); +await op('cocos_inspect_node', { nid: node.nid }); +await op('cocos_set_node_name', { + nid: node.nid, + name: 'AGC_BeforeEditorUndo', +}); +await op('cocos_editor_undo'); +assert.equal( + (await op('cocos_inspect_node', { nid: node.nid })).name, + 'AGC_Renamed', +); +await op('cocos_delete_node', { nid: node.nid, confirm: true, cascade: true }); +await op('cocos_save_scene'); + +for (const [kind, args] of [ + ['shape', { shape: 'circle', color: '#FF0000' }], + ['label', { text: 'Cocos smoke', fontSize: 24 }], + ['sprite', { assetPath: 'assets/agc-ui-shapes/circle.png', nineSlice: true }], + ['button', { label: 'Start', color: '#0088FF' }], +]) { + await op('cocos_create_ui_' + kind, { + parentNid: parent, + name: 'AGC_UI_' + kind, + ...args, + save: true, + }); + await op('cocos_mcp_undo_last'); +} +await op('cocos_apply_ui_spec', { + parentNid: parent, + save: true, + nodes: [ + { + kind: 'container', + layout: { type: 'horizontal', spacingX: 12 }, + children: [ + { kind: 'shape', shape: 'circle', color: '#FF0000' }, + { kind: 'shape', shape: 'rectangle', color: '#0000FF' }, + { kind: 'button', buttonLabel: 'Start' }, + ], + }, + ], +}); +await op('cocos_mcp_undo_last'); +assert.equal( + await evalScene( + 'cce.SceneFacadeManager.getCurrentFacade()._sceneProxy.serialize(true)', + ), + baseline, +); + +await op('cocos_get_log_tail', { maxLines: 10 }); +await op('cocos_get_build_diagnostics', { maxFiles: 2, maxLinesPerFile: 20 }); +await op('cocos_diagnose', { maxLogLines: 10 }); +await op('cocos_list_assets', { extensions: ['png'], maxResults: 10 }); +const seed = await op('cocos_create_node', { + parentNid: parent, + name: 'AGC_PrefabSeed', +}); +const data = await evalScene( + '(()=>{const cc=require("cc"),p=new cc.Prefab();p.data=cc.instantiate(cce.Node.query(' + + JSON.stringify(seed.uuid) + + '));try{return cce.Utils.serialize(p);}finally{p.data.destroy();p.destroy();}})()', +); +const prefabPath = 'assets/AGC-Smoke-' + Date.now() + '.prefab'; +const asset = await raw( + "return await Editor.Message.request('asset-db','create-asset'," + + JSON.stringify('db://' + prefabPath) + + ',' + + JSON.stringify(data) + + ');', +); +assert.equal(asset.ok, true, asset.error); +await op('cocos_get_prefab_info', { prefabPath }); +const instance = await op('cocos_instantiate_prefab', { + parentNid: parent, + prefabPath, +}); +await op('cocos_delete_node', { + nid: instance.nid, + confirm: true, + cascade: true, +}); +await op('cocos_delete_node', { nid: seed.nid, confirm: true, cascade: true }); +await op('cocos_save_scene'); +assert.equal( + ( + await raw( + "return await Editor.Message.request('scene','load-empty-scene');", + ) + ).ok, + true, +); +await op('cocos_save_scene', { + path: 'assets/AGC-Fresh-' + Date.now() + '.scene', +}); +assert.equal( + ( + await raw( + "return await Editor.Message.request('scene','open-scene'," + + JSON.stringify(mainUuid) + + ');', + ) + ).ok, + true, +); + +const urls = await raw( + "return require('electron').webContents.getAllWebContents().filter(w=>w.getType()==='webview').map(w=>w.getURL()).filter(url=>url.startsWith('http://localhost:'));", +); +const url = new URL(urls.result[0]).origin + '/'; +await op('cocos_preview_debug_start', { url }); +try { + await new Promise((resolve) => setTimeout(resolve, 1500)); + await op('cocos_preview_debug_read', { maxEvents: 30 }); + const capture = await op('cocos_preview_debug_capture'); + assert.equal(capture.__image.mimeType, 'image/png'); + fs.writeFileSync( + path.join(project, 'temp/agc-preview-smoke.png'), + Buffer.from(capture.__image.data, 'base64'), + ); +} finally { + await op('cocos_preview_debug_stop'); +} +const names = new Set(report.map((item) => item.name)); +assert.deepEqual([...names].sort(), [...COCOS_EDITOR_OPERATIONS].sort()); +console.log('36/36 Cocos operations passed; report: ' + reportPath); diff --git a/plugins/agc-cocos-editor/src/cocos-editor-operations.mjs b/plugins/agc-cocos-editor/src/cocos-editor-operations.mjs new file mode 100644 index 000000000..ae2d8a4a5 --- /dev/null +++ b/plugins/agc-cocos-editor/src/cocos-editor-operations.mjs @@ -0,0 +1,51 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const validate = require('./operations/validate.cjs'); +const directory = new URL('./operations/', import.meta.url); +const read = (name) => fs.readFileSync(new URL(name, directory), 'utf8'); +export const COCOS_OPERATION_CATALOG = Object.freeze( + JSON.parse(read('catalog.json')), +); +export const COCOS_EDITOR_OPERATIONS = Object.freeze( + COCOS_OPERATION_CATALOG.map((tool) => tool.name), +); +const template = read('invoke.js.txt'); +const sources = Object.fromEntries( + [ + 'assets.cjs', + 'main.cjs', + 'paths.cjs', + 'preview.cjs', + 'scene.cjs', + 'validate.cjs', + ].map((name) => [name, read(name)]), +); +const sourcesJson = JSON.stringify(sources); +const version = createHash('sha256') + .update(sourcesJson) + .update(template) + .digest('hex'); + +export function buildCocosOperationCode(operation, args = {}) { + const tool = COCOS_OPERATION_CATALOG.find((item) => item.name === operation); + if (!tool) throw new Error('不支持的 Cocos 操作:' + operation); + validate(tool.inputSchema, args); + const replacements = { + __AGC_SOURCES__: sourcesJson, + __AGC_VERSION__: JSON.stringify(version), + __AGC_SCHEMA__: JSON.stringify(tool.inputSchema), + __AGC_NAMES__: JSON.stringify(COCOS_EDITOR_OPERATIONS), + __AGC_OPERATION__: JSON.stringify(operation), + __AGC_ARGS__: JSON.stringify(args), + }; + const code = template.replace( + /__AGC_[A-Z]+__/g, + (marker) => replacements[marker], + ); + if (Buffer.byteLength(code) > 128 * 1024) + throw new Error('Cocos 操作超过代码传输上限'); + return code; +} diff --git a/plugins/agc-cocos-editor/src/entry.mjs b/plugins/agc-cocos-editor/src/entry.mjs index 76e44c910..6457863e8 100644 --- a/plugins/agc-cocos-editor/src/entry.mjs +++ b/plugins/agc-cocos-editor/src/entry.mjs @@ -18,9 +18,14 @@ import { COCOS_EDITOR_OPERATIONS, validateExecuteCode, } from './cocos-editor-adapter.mjs'; +import { + buildCocosOperationCode, + COCOS_EDITOR_OPERATIONS as COCOS_OPERATION_NAMES, +} from './cocos-editor-operations.mjs'; export const COCOS_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1'; export const COCOS_EXECUTE_COMMAND_ID = 'cocos.editor.execute'; +export const COCOS_OPERATION_COMMAND_ID = 'cocos.editor.operation'; export const COCOS_CONNECTION_CAPABILITY_ID = 'cocos.editor.connection'; export const COCOS_EDITOR_PANEL = Object.freeze({ id: 'cocos-editor', @@ -47,6 +52,7 @@ export function createCocosEditorPlugin({ const handlers = new Map([ [COCOS_EXECUTE_COMMAND_ID, handleExecute], + [COCOS_OPERATION_COMMAND_ID, handleOperation], [COCOS_CONNECTION_CAPABILITY_ID, handleConnection], ]); @@ -112,7 +118,11 @@ export function createCocosEditorPlugin({ }; executionPending = true; try { - const response = await callEditor('execute', { projectPath, code }); + const response = await callEditor('execute', { + projectPath, + code, + timeoutMs: params?.timeoutMs, + }); if (response?.status === 'needs-reconciliation') { executionUncertain = true; return reconcile(response); @@ -146,6 +156,28 @@ export function createCocosEditorPlugin({ }); } + async function handleOperation(params) { + const operation = params?.operation; + if (!COCOS_OPERATION_NAMES.includes(operation)) { + throw new Error(`不支持的 Cocos 操作:${operation}`); + } + const projectPath = resolveProjectPath(params); + const result = await handleExecute({ + projectPath, + code: buildCocosOperationCode(operation, params?.args ?? {}), + timeoutMs: 60_000, + }); + const status = result?.response?.result?.status; + if (status === 'failed' || status === 'needs-reconciliation') { + result.status = status; + if (status === 'needs-reconciliation') { + executionUncertain = true; + result.retryAllowed = false; + } + } + return result; + } + async function handleMessage(message) { if (disposed) return; const envelope = @@ -199,6 +231,11 @@ export function createCocosEditorPlugin({ id: COCOS_CONNECTION_CAPABILITY_ID, description: 'Cocos Creator 连接探测、目标预检、ping、status 与注入', }); + const operations = await request('host.registerCommand', { + id: COCOS_OPERATION_COMMAND_ID, + title: '调用 Cocos Creator 编辑器能力', + description: `调用内置 Cocos 编辑器操作(${COCOS_OPERATION_NAMES.join(', ')})`, + }); const panel = await request('host.registerPanel', { ...COCOS_EDITOR_PANEL, }); @@ -209,6 +246,7 @@ export function createCocosEditorPlugin({ return { command, capability, + operations, panel, subscriptionId: subscription?.subscriptionId ?? null, }; diff --git a/plugins/agc-cocos-editor/src/entry.test.mjs b/plugins/agc-cocos-editor/src/entry.test.mjs index 4894143b3..00831b843 100644 --- a/plugins/agc-cocos-editor/src/entry.test.mjs +++ b/plugins/agc-cocos-editor/src/entry.test.mjs @@ -13,6 +13,7 @@ import { COCOS_CONNECTION_CAPABILITY_ID, COCOS_EDITOR_PANEL, COCOS_EXECUTE_COMMAND_ID, + COCOS_OPERATION_COMMAND_ID, COCOS_PLUGIN_PROTOCOL_VERSION, createCocosEditorPlugin, PROJECT_CHANGED_EVENT, @@ -43,6 +44,8 @@ async function startPlugin(harness, projectPath = 'C:\\demo') { await tick(); harness.respond(harness.outbound.at(-1).id, { registered: true }); await tick(); + harness.respond(harness.outbound.at(-1).id, { registered: true }); + await tick(); harness.respond(harness.outbound.at(-1).id, { subscriptionId: 'sub-1', projectPath, @@ -57,17 +60,67 @@ test('runtime entry registers command, capability, panel and project event', asy assert.deepEqual(methods, [ 'host.registerCommand', 'host.registerCapability', + 'host.registerCommand', 'host.registerPanel', 'host.events.subscribe', ]); assert.equal(harness.outbound[0].params.id, COCOS_EXECUTE_COMMAND_ID); assert.equal(harness.outbound[1].params.id, COCOS_CONNECTION_CAPABILITY_ID); - assert.deepEqual(harness.outbound[2].params, { ...COCOS_EDITOR_PANEL }); - assert.equal(harness.outbound[3].params.type, PROJECT_CHANGED_EVENT); + assert.equal(harness.outbound[2].params.id, COCOS_OPERATION_COMMAND_ID); + assert.deepEqual(harness.outbound[3].params, { ...COCOS_EDITOR_PANEL }); + assert.equal(harness.outbound[4].params.type, PROJECT_CHANGED_EVENT); assert.equal(result.subscriptionId, 'sub-1'); assert.equal(harness.plugin.activeProjectPath, 'C:\\demo'); }); +test('operation command validates and routes a named Cocos operation', async () => { + const harness = createHarness(); + await startPlugin(harness); + const handling = harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 78, + method: COCOS_OPERATION_COMMAND_ID, + params: { operation: 'cocos_get_hierarchy', args: { maxNodes: 10 } }, + }); + await tick(); + const rpc = harness.outbound.at(-1); + assert.equal(rpc.method, 'host.rpc'); + assert.equal(rpc.params.method, 'editor.execute'); + assert.match(rpc.params.params.code, /cocos_get_hierarchy/); + assert.equal(rpc.params.params.timeoutMs, 60_000); + harness.respond(rpc.id, { ok: true }); + await handling; + assert.equal(harness.outbound.at(-1).result.status, 'completed'); +}); + +test('semantic reconciliation from an operation blocks later execute', async () => { + const harness = createHarness(); + await startPlugin(harness); + const handling = harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 178, + method: COCOS_OPERATION_COMMAND_ID, + params: { operation: 'cocos_get_hierarchy', args: {} }, + }); + await tick(); + await harness.respond(harness.outbound.at(-1).id, { + ok: true, + result: { status: 'needs-reconciliation', rollbackError: 'restore failed' }, + }); + await handling; + assert.equal(harness.outbound.at(-1).result.status, 'needs-reconciliation'); + const count = harness.outbound.length; + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 179, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 1;' }, + }); + assert.equal(harness.outbound.length, count + 1); + assert.equal(harness.outbound.at(-1).result.retryAllowed, false); + harness.plugin.dispose(); +}); + test('execute command routes through host.rpc with the active project', async () => { const harness = createHarness(); await startPlugin(harness); diff --git a/plugins/agc-cocos-editor/src/operations/assets.cjs b/plugins/agc-cocos-editor/src/operations/assets.cjs new file mode 100644 index 000000000..c640c127a --- /dev/null +++ b/plugins/agc-cocos-editor/src/operations/assets.cjs @@ -0,0 +1,205 @@ +'use strict'; + +module.exports = function assetOperations(Editor, require, paths) { + const fs = require('node:fs'), + path = require('node:path'), + zlib = require('node:zlib'); + const request = (...args) => Editor.Message.request('asset-db', ...args); + function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; + } + function shapePng(shape) { + const size = 64, + rowBytes = size * 4 + 1, + raw = Buffer.alloc(size * rowBytes); + for (let y = 0; y < size; y++) + for (let x = 0; x < size; x++) { + let coverage = 0; + for (const dx of [0.25, 0.75]) + for (const dy of [0.25, 0.75]) { + const px = x + dx, + py = y + dy; + const inside = + shape === 'circle' + ? Math.hypot(px - 32, py - 32) <= 31 + : shape === 'rounded-rectangle' + ? Math.hypot( + Math.max(12 - px, 0, px - 52), + Math.max(12 - py, 0, py - 52), + ) <= 11 + : true; + if (inside) coverage++; + } + const at = y * rowBytes + 1 + x * 4; + raw[at] = 255; + raw[at + 1] = 255; + raw[at + 2] = 255; + raw[at + 3] = Math.round((255 * coverage) / 4); + } + function chunk(type, body) { + const t = Buffer.from(type), + len = Buffer.alloc(4), + crc = Buffer.alloc(4); + len.writeUInt32BE(body.length); + crc.writeUInt32BE(crc32(Buffer.concat([t, body]))); + return Buffer.concat([len, t, body, crc]); + } + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header[8] = 8; + header[9] = 6; + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', header), + chunk('IDAT', zlib.deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); + } + async function info(relative, type) { + const url = paths.assetUrl(relative), + asset = await request('query-asset-info', url); + if (!asset || asset.invalid || asset.isDirectory) + throw new Error('资源不存在或未正确导入:' + relative); + if ( + type && + !String(asset.type || asset.importer) + .toLowerCase() + .includes(type) + ) + throw new Error('资源类型不匹配:' + relative); + return asset; + } + function spriteIn(value) { + if (!value || typeof value !== 'object') return null; + if (/sprite.?frame/i.test(String(value.type || value.importer))) + return value.uuid; + for (const sub of Object.values(value.subAssets || value.subMetas || {})) { + const found = spriteIn(sub); + if (found) return found; + } + return null; + } + async function spriteFrame(relative, managed = false) { + const asset = await info(relative); + let uuid = spriteIn(asset); + if (uuid) return uuid; + const meta = await request('query-asset-meta', asset.uuid); + uuid = spriteIn(meta); + if (uuid) return uuid; + if (!managed) throw new Error('图片尚未导入为 SpriteFrame:' + relative); + if (!meta || typeof meta !== 'object') + throw new Error('无法读取模板图片导入配置'); + meta.userData = { ...meta.userData, type: 'sprite-frame' }; + await request('save-asset-meta', asset.uuid, JSON.stringify(meta)); + await request('reimport-asset', asset.uuid); + for (let attempt = 0; attempt < 50; attempt++) { + const fresh = await request('query-asset-info', asset.uuid); + uuid = + spriteIn(fresh) || + spriteIn(await request('query-asset-meta', asset.uuid)); + if (uuid) return uuid; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('模板图片 SpriteFrame 导入未在 5 秒内完成'); + } + async function shape(shape) { + if (!['circle', 'rectangle', 'rounded-rectangle'].includes(shape)) + throw new Error('未知形状'); + const relative = 'assets/agc-ui-shapes/' + shape + '.png', + file = paths.resolve(relative), + bytes = shapePng(shape); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + paths.resolve(relative); + try { + await fs.promises.writeFile(file, bytes, { flag: 'wx' }); + } catch (e) { + if (e.code !== 'EEXIST') throw e; + if (!(await fs.promises.readFile(file)).equals(bytes)) + throw new Error('同名模板资产内容不符,拒绝覆盖'); + } + await request('refresh-asset', 'db://assets/agc-ui-shapes'); + const uuid = await spriteFrame(relative, true); + const meta = await request('query-asset-meta', paths.assetUrl(relative)); + const sub = Object.values(meta.subMetas || {}).find((v) => v.uuid === uuid); + if (!sub) throw new Error('模板 SpriteFrame 元数据缺失'); + const values = { + trimType: 'none', + borderTop: shape === 'rounded-rectangle' ? 12 : 0, + borderBottom: shape === 'rounded-rectangle' ? 12 : 0, + borderLeft: shape === 'rounded-rectangle' ? 12 : 0, + borderRight: shape === 'rounded-rectangle' ? 12 : 0, + }; + if (Object.entries(values).some(([k, v]) => sub.userData?.[k] !== v)) { + sub.userData = { ...sub.userData, ...values }; + await request( + 'save-asset-meta', + paths.assetUrl(relative), + JSON.stringify(meta), + ); + await request('reimport-asset', paths.assetUrl(relative)); + } + return uuid; + } + async function prefab(relative) { + const asset = await info(relative, 'prefab'); + const file = paths.resolve(relative), + stat = await fs.promises.stat(file); + if (stat.size > 2 * 1024 * 1024) throw new Error('Prefab 超过读取上限'); + const data = JSON.parse(await fs.promises.readFile(file, 'utf8')); + return { + uuid: asset.uuid, + url: asset.url, + imported: asset.imported, + nodes: data + .filter((v) => v?.__type__ === 'cc.Node') + .map((v) => ({ + name: v._name, + uuid: v._id, + childCount: v._children?.length ?? 0, + })), + componentTypes: [ + ...new Set( + data + .map((v) => v?.__type__) + .filter((v) => v && v !== 'cc.Node' && v !== 'cc.Prefab'), + ), + ], + }; + } + async function list(args) { + const relative = + 'assets' + + (args.relativeDir ? '/' + args.relativeDir.replace(/^assets\//, '') : ''); + paths.resolve(relative); + const all = await request('query-assets', { + pattern: 'db://' + relative + '/**/*', + }); + const extensions = args.extensions?.map( + (v) => '.' + v.replace(/^\./, '').toLowerCase(), + ); + const filtered = (all || []).filter( + (a) => + !a.isDirectory && + (!extensions || + extensions.some((ext) => a.url?.toLowerCase().endsWith(ext))), + ); + return { + assets: filtered.slice(0, args.maxResults ?? 200).map((a) => ({ + uuid: a.uuid, + url: a.url, + type: a.type, + importer: a.importer, + invalid: a.invalid, + })), + truncated: filtered.length > (args.maxResults ?? 200), + }; + } + return { info, spriteFrame, shape, prefab, list, shapePng }; +}; diff --git a/plugins/agc-cocos-editor/src/operations/catalog.json b/plugins/agc-cocos-editor/src/operations/catalog.json new file mode 100644 index 000000000..545d8d5ea --- /dev/null +++ b/plugins/agc-cocos-editor/src/operations/catalog.json @@ -0,0 +1,1839 @@ +[ + { + "name": "cocos_ping", + "description": "确认客户端内置 Cocos 桥接和场景运行时可达。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_get_capabilities", + "description": "读取内置 Cocos 操作清单、限制、参数和撤销语义。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_get_project_info", + "description": "读取当前绑定项目名称、Creator 版本和工程信息。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_get_current_scene_meta", + "description": "读取当前场景 UUID、名称、资源 URL 和未保存状态。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_get_log_tail", + "description": "有界读取当前项目日志;文件缺失明确返回 missing。", + "inputSchema": { + "type": "object", + "properties": { + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "maxLines": { + "type": "integer", + "minimum": 1, + "maximum": 2000 + } + }, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_diagnose", + "description": "汇总当前桥接、场景和项目日志的实际诊断证据。", + "inputSchema": { + "type": "object", + "properties": { + "maxLogLines": { + "type": "integer", + "minimum": 1, + "maximum": 2000 + } + }, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_get_build_diagnostics", + "description": "有界读取项目已有构建日志,提取错误与警告,不启动构建。", + "inputSchema": { + "type": "object", + "properties": { + "maxFiles": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "maxLinesPerFile": { + "type": "integer", + "minimum": 20, + "maximum": 2000 + } + }, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_list_assets", + "description": "按 assets 相对目录、扩展名筛选已导入资源,返回 UUID 和 URL。", + "inputSchema": { + "type": "object", + "properties": { + "relativeDir": { + "type": "string", + "maxLength": 512 + }, + "extensions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 64 + }, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 2000 + } + }, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_get_prefab_info", + "description": "读取指定 Prefab 的导入状态、节点和组件摘要。", + "inputSchema": { + "type": "object", + "properties": { + "prefabPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": ["prefabPath"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_get_hierarchy", + "description": "读取有界场景树,返回稳定 NID、UUID、组件和父子关系。", + "inputSchema": { + "type": "object", + "properties": { + "maxDepth": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "maxNodes": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + }, + "includeInternal": { + "type": "boolean" + }, + "includeEditorDecorations": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_search_nodes", + "description": "按名称包含和组件类型筛选场景节点。", + "inputSchema": { + "type": "object", + "properties": { + "nameSubstring": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "componentFilter": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + "includeInternal": { + "type": "boolean" + }, + "includeEditorDecorations": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_inspect_node", + "description": "读取节点变换及带索引和类型的组件属性。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + } + }, + "required": ["nid"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_set_node_active", + "description": "修改节点激活状态并回读,可撤销。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "active": { + "type": "boolean" + } + }, + "required": ["nid", "active"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_set_node_transform", + "description": "修改局部位置、欧拉角或缩放并回读。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "y": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "z": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "rotationEuler": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "y": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "z": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "scale": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "y": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "z": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + } + }, + "required": ["nid"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_set_node_name", + "description": "重命名节点并回读。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": ["nid", "name"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_create_node", + "description": "在指定父节点下创建节点,返回 NID 和 UUID。", + "inputSchema": { + "type": "object", + "properties": { + "parentNid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": ["parentNid", "name"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_delete_node", + "description": "删除节点;必须 confirm:true,有子节点还需 cascade:true;支持 MCP 撤销。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "confirm": { + "const": true + }, + "cascade": { + "type": "boolean" + } + }, + "required": ["nid", "confirm"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_reparent_node", + "description": "修改父节点,拒绝循环和移动场景根,可保留世界变换。", + "inputSchema": { + "type": "object", + "properties": { + "childNid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "parentNid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "keepWorldTransform": { + "type": "boolean" + } + }, + "required": ["childNid", "parentNid"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_set_node_sibling_index", + "description": "调整兄弟节点顺序并回读。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + } + }, + "required": ["nid", "index"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_duplicate_node", + "description": "复制节点及子树,保留组件,返回新节点身份。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + } + }, + "required": ["nid"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_instantiate_prefab", + "description": "通过 AssetDB 解析 assets/...prefab 并在父节点下实例化。", + "inputSchema": { + "type": "object", + "properties": { + "parentNid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "prefabPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": ["parentNid", "prefabPath"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_set_component_property", + "description": "按 inspect 返回的组件索引修改可序列化属性;颜色支持 #RRGGBBAA,资源支持 assets 路径或 UUID,回读验证。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "componentIndex": { + "type": "integer", + "minimum": 0, + "maximum": 1000 + }, + "property": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string", + "maxLength": 8192 + }, + { + "type": "null" + }, + { + "type": "object", + "properties": { + "width": { + "type": "number", + "minimum": 0, + "maximum": 1000000000 + }, + "height": { + "type": "number", + "minimum": 0, + "maximum": 1000000000 + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "y": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "z": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": ["x", "y"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "r": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "g": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "b": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "a": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + }, + "required": ["r", "g", "b"], + "additionalProperties": false + } + ] + } + }, + "required": ["nid", "componentIndex", "property", "value"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_add_component", + "description": "添加内置或已注册组件,返回真实组件索引和 UUID。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "componentType": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": ["nid", "componentType"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_remove_component", + "description": "删除组件;可传预期类型防止索引漂移。", + "inputSchema": { + "type": "object", + "properties": { + "nid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "componentIndex": { + "type": "integer", + "minimum": 0, + "maximum": 1000 + }, + "expectedComponentType": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": ["nid", "componentIndex"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_create_ui_shape", + "description": "创建持久可见的圆/方/圆角矩形,配置 Sprite,验证并可整步撤销。", + "inputSchema": { + "type": "object", + "properties": { + "parentNid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "y": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "z": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + }, + "height": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "widget": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "bottom": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "left": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "right": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "horizontalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "verticalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": [], + "additionalProperties": false + }, + "save": { + "type": "boolean" + }, + "shape": { + "type": "string", + "enum": ["circle", "rectangle", "rounded-rectangle"] + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$" + } + }, + "required": ["parentNid"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_create_ui_label", + "description": "创建并配置 Label 文字、字体、颜色和对齐,验证并可撤销。", + "inputSchema": { + "type": "object", + "properties": { + "parentNid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "y": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "z": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + }, + "height": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "widget": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "bottom": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "left": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "right": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "horizontalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "verticalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": [], + "additionalProperties": false + }, + "save": { + "type": "boolean" + }, + "text": { + "type": "string", + "maxLength": 8192 + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$" + }, + "fontSize": { + "type": "number", + "minimum": 1, + "maximum": 1024 + }, + "lineHeight": { + "type": "number", + "minimum": 1, + "maximum": 2048 + }, + "horizontalAlign": { + "type": "string", + "enum": ["left", "center", "right"] + }, + "verticalAlign": { + "type": "string", + "enum": ["top", "center", "bottom"] + } + }, + "required": ["parentNid", "text"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_create_ui_sprite", + "description": "从 assets 图片创建 Sprite,支持九宫格与颜色。", + "inputSchema": { + "type": "object", + "properties": { + "parentNid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "y": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "z": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + }, + "height": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "widget": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "bottom": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "left": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "right": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "horizontalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "verticalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": [], + "additionalProperties": false + }, + "save": { + "type": "boolean" + }, + "assetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$" + }, + "nineSlice": { + "type": "boolean" + } + }, + "required": ["parentNid", "assetPath"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_create_ui_button", + "description": "创建可见背景、Button 组件和居中文字子节点,整步撤销。", + "inputSchema": { + "type": "object", + "properties": { + "parentNid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "y": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "z": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + }, + "height": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "widget": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "bottom": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "left": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "right": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "horizontalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "verticalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": [], + "additionalProperties": false + }, + "save": { + "type": "boolean" + }, + "label": { + "type": "string", + "maxLength": 512 + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$" + }, + "fontSize": { + "type": "number", + "minimum": 1, + "maximum": 1024 + }, + "interactable": { + "type": "boolean" + } + }, + "required": ["parentNid", "label"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_apply_ui_spec", + "description": "事务创建最多 64 个节点、12 层 UI,支持 Layout/Widget/九宫格;运行态回读,失败回滚,整步撤销。", + "inputSchema": { + "type": "object", + "properties": { + "parentNid": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + ], + "description": "使用查询返回的 NID 或 UUID;场景切换后重新查询。" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/$defs/uiNode" + }, + "minItems": 1, + "maxItems": 64 + }, + "save": { + "type": "boolean" + } + }, + "required": ["parentNid", "nodes"], + "additionalProperties": false, + "$defs": { + "uiNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "kind": { + "type": "string", + "enum": ["container", "shape", "label", "sprite", "button"] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "active": { + "type": "boolean" + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "y": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "z": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + }, + "height": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$" + }, + "shape": { + "type": "string", + "enum": ["circle", "rectangle", "rounded-rectangle"] + }, + "text": { + "type": "string", + "maxLength": 8192 + }, + "fontSize": { + "type": "number", + "minimum": 1, + "maximum": 1024 + }, + "lineHeight": { + "type": "number", + "minimum": 1, + "maximum": 2048 + }, + "horizontalAlign": { + "type": "string", + "enum": ["left", "center", "right"] + }, + "verticalAlign": { + "type": "string", + "enum": ["top", "center", "bottom"] + }, + "assetPath": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "nineSlice": { + "type": "boolean" + }, + "interactable": { + "type": "boolean" + }, + "buttonLabel": { + "type": "string", + "maxLength": 512 + }, + "layout": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["horizontal", "vertical", "grid"] + }, + "resizeMode": { + "type": "string", + "enum": ["none", "container", "children"] + }, + "spacingX": { + "type": "number", + "minimum": 0, + "maximum": 1000000000 + }, + "spacingY": { + "type": "number", + "minimum": 0, + "maximum": 1000000000 + }, + "paddingLeft": { + "type": "number", + "minimum": 0, + "maximum": 1000000000 + }, + "paddingRight": { + "type": "number", + "minimum": 0, + "maximum": 1000000000 + }, + "paddingTop": { + "type": "number", + "minimum": 0, + "maximum": 1000000000 + }, + "paddingBottom": { + "type": "number", + "minimum": 0, + "maximum": 1000000000 + }, + "cellSize": { + "type": "object", + "properties": { + "width": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + }, + "height": { + "type": "number", + "minimum": 0.01, + "maximum": 16384 + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "constraint": { + "type": "string", + "enum": ["none", "fixed-row", "fixed-column"] + }, + "constraintNum": { + "type": "integer", + "minimum": 1, + "maximum": 64 + } + }, + "required": ["type"], + "additionalProperties": false + }, + "widget": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "bottom": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "left": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "right": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "horizontalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + }, + "verticalCenter": { + "type": "number", + "minimum": -1000000000, + "maximum": 1000000000 + } + }, + "required": [], + "additionalProperties": false + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/uiNode" + }, + "maxItems": 64 + } + }, + "required": ["kind"], + "additionalProperties": false + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_preview_debug_start", + "description": "在插件自有 Chromium 窗口打开当前项目的 loopback 网页预览,采集 console、JS 异常和失败网络请求。", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "timeoutMs": { + "type": "integer", + "minimum": 3000, + "maximum": 30000 + } + }, + "required": ["url"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_preview_debug_read", + "description": "读取插件托管预览事件;缺省只读最近事件,不清除缓存。", + "inputSchema": { + "type": "object", + "properties": { + "maxEvents": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "errorsOnly": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_preview_debug_capture", + "description": "截图当前插件托管的预览窗口,返回 PNG。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "cocos_preview_debug_stop", + "description": "关闭本插件创建的预览窗口,保留用户的其它编辑器和浏览器。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_save_scene", + "description": "保存当前场景;首次保存可传 assets 下的新 .scene 路径,缺省 assets/Main.scene;拒绝覆盖其它场景。", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_editor_undo", + "description": "执行一次 Cocos 官方编辑器撤销并报告实际变化。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "cocos_mcp_undo_last", + "description": "撤销最近一次内置 Cocos 编辑事务;用户已做后续编辑时拒绝覆盖。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + } + } +] diff --git a/plugins/agc-cocos-editor/src/operations/invoke.js.txt b/plugins/agc-cocos-editor/src/operations/invoke.js.txt new file mode 100644 index 000000000..785f73ebf --- /dev/null +++ b/plugins/agc-cocos-editor/src/operations/invoke.js.txt @@ -0,0 +1,22 @@ +const sources = __AGC_SOURCES__; +const version = __AGC_VERSION__; +const schema = __AGC_SCHEMA__; +const operationNames = __AGC_NAMES__; +const key = Symbol.for('agc.cocos.operations'); +let instance = globalThis[key]; +if (!instance || instance.version !== version) { + if (instance) await instance.runtime.dispose(); + const cache = {}; + const load = (name) => { + if (!Object.hasOwn(sources, name)) throw new Error('未登记的内置模块'); + if (!cache[name]) { + const module = { exports: {} }; + new Function('module', 'exports', sources[name])(module, module.exports); + cache[name] = module.exports; + } + return cache[name]; + }; + instance = { version, runtime: load('main.cjs')(Editor, require, load, sources, version, operationNames) }; + globalThis[key] = instance; +} +return await instance.runtime.execute(__AGC_OPERATION__, __AGC_ARGS__, schema); diff --git a/plugins/agc-cocos-editor/src/operations/main.cjs b/plugins/agc-cocos-editor/src/operations/main.cjs new file mode 100644 index 000000000..cf480141c --- /dev/null +++ b/plugins/agc-cocos-editor/src/operations/main.cjs @@ -0,0 +1,380 @@ +'use strict'; + +module.exports = function createOperations( + Editor, + require, + load, + sources, + version, + operationNames, +) { + const { webContents } = require('electron'); + const paths = load('paths.cjs')(Editor.Project.path, require); + const assets = load('assets.cjs')(Editor, require, paths); + const preview = load('preview.cjs')(require); + const validate = load('validate.cjs'); + let busy = false; + function sceneView() { + const candidates = webContents + .getAllWebContents() + .filter( + (w) => + w.getType() === 'webview' && + w.getURL().startsWith('packages://scene/'), + ); + if (candidates.length !== 1) + throw new Error('需要唯一已就绪的 Cocos 场景运行窗口'); + return candidates[0]; + } + async function scene(operation, args = {}) { + const expression = + '(async()=>{try{const key=Symbol.for("agc.cocos.scene.operations");' + + 'if(!globalThis[key]||globalThis[key].version!==' + + JSON.stringify(version) + + '){' + + 'const m={exports:{}};new Function("module","exports",' + + JSON.stringify(sources['scene.cjs']) + + ')(m,m.exports);' + + 'globalThis[key]={version:' + + JSON.stringify(version) + + ',runtime:m.exports(require("cc"),cce,Editor)};}' + + 'return await globalThis[key].runtime.call(' + + JSON.stringify(operation) + + ',' + + JSON.stringify(args) + + ');' + + '}catch(e){return {__sceneError:e.stack||e.message};}})()'; + const result = await sceneView().executeJavaScript(expression); + if (result?.__sceneError) throw new Error(result.__sceneError); + return result; + } + async function save(relative) { + const meta = await scene('meta'); + if (!meta.hasScene) throw new Error('当前没有可保存场景'); + const existing = await Editor.Message.request( + 'asset-db', + 'query-asset-info', + meta.sceneAssetUuid, + ); + if (existing) { + if (relative && paths.assetUrl(relative) !== existing.url) + throw new Error('当前场景已命名,请保存当前场景;不隐式另存为'); + const uuid = await Editor.Message.request('scene', 'save-scene'); + if (uuid !== existing.uuid) throw new Error('保存回执 UUID 不匹配'); + const after = await scene('meta'); + if (after.dirty) throw new Error('保存后仍有未提交修改'); + await scene('save-receipt'); + return { saved: true, uuid, url: existing.url, dirty: false }; + } + relative ??= 'assets/Main.scene'; + if (!relative.endsWith('.scene')) + throw new Error('场景保存路径必须以 .scene 结尾'); + const url = paths.assetUrl(relative); + if ( + (await Editor.Message.request('asset-db', 'query-asset-info', url)) || + require('node:fs').existsSync(paths.resolve(relative)) + ) + throw new Error('目标场景已存在,拒绝覆盖'); + const serialized = await scene('serialize'); + const asset = await Editor.Message.request( + 'asset-db', + 'create-asset', + url, + serialized, + ); + if (!asset?.uuid) throw new Error('AssetDB 没有创建场景'); + let ready = false; + for (let attempt = 0; attempt < 50; attempt++) { + const info = await Editor.Message.request( + 'asset-db', + 'query-asset-info', + asset.uuid, + ); + if ( + info?.imported && + !info.invalid && + (await Editor.Message.request( + 'asset-db', + 'query-asset-meta', + asset.uuid, + )) + ) { + ready = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!ready) + throw new Error('场景文件已创建,但资源导入未完成:' + relative); + // 序列化内容已经交给 AssetDB;先标记这个快照已保存,再走官方打开入口,不篡改场景 UUID。 + await scene('mark-saved'); + await Editor.Message.request('scene', 'open-scene', asset.uuid); + const after = await scene('meta'); + if (after.sceneAssetUuid !== asset.uuid || after.dirty) + throw new Error('首次保存后回读失败'); + return { saved: true, uuid: asset.uuid, url, dirty: false }; + } + function uiSpecs(operation, args) { + if (operation === 'cocos_apply_ui_spec') return structuredClone(args.nodes); + const kind = operation.slice('cocos_create_ui_'.length); + const { parentNid: _parentNid, save: _save, ...node } = args; + if (kind === 'button') { + node.buttonLabel = node.label; + delete node.label; + } + return [{ ...node, kind }]; + } + function validateUi(nodes) { + let count = 0; + const ids = new Set(); + const walk = (list, depth) => { + if (depth > 12) throw new Error('UI 层级超过 12 层'); + for (const node of list) { + count += node.kind === 'button' ? 2 : 1; + if (count > 64) + throw new Error('UI 节点超过 64 个(含按钮文字子节点)'); + if (node.id) { + if (ids.has(node.id)) throw new Error('UI id 重复'); + ids.add(node.id); + } + if (node.kind === 'sprite' && !node.assetPath) + throw new Error('Sprite 必须指定 assetPath'); + if (node.children) walk(node.children, depth + 1); + } + }; + walk(nodes, 1); + return count; + } + async function prepareUi(nodes) { + for (const node of nodes) { + if (node.kind === 'shape' || node.kind === 'button') + node.assetUuid = await assets.shape( + node.shape || + (node.kind === 'button' ? 'rounded-rectangle' : 'rectangle'), + ); + if (node.kind === 'sprite') + node.assetUuid = await assets.spriteFrame(node.assetPath); + if (node.children) await prepareUi(node.children); + } + } + async function execute(operation, args, schema) { + validate(schema, args); + if (busy) throw new Error('已有 Cocos 操作执行中,不能积压重放'); + busy = true; + try { + let result; + switch (operation) { + case 'cocos_get_capabilities': + result = { + operations: operationNames, + ui: { + maxNodes: 64, + maxDepth: 12, + persistentShapes: true, + runtimeVerification: true, + transactionUndo: true, + }, + identity: 'NID 或 UUID;换场景重新查询', + undo: '只撤销当前场景与最近事务后状态一致的操作', + preview: '插件独立 Chromium 窗口,仅当前项目 loopback 地址', + }; + break; + case 'cocos_ping': + result = { + ready: true, + creatorVersion: Editor.App?.version, + scene: await scene('meta'), + }; + break; + case 'cocos_get_project_info': { + const pkg = JSON.parse( + await require('node:fs').promises.readFile( + paths.resolve('package.json'), + 'utf8', + ), + ); + result = { + name: + pkg.name || require('node:path').basename(Editor.Project.path), + creator: pkg.creator, + version: Editor.App?.version, + }; + break; + } + case 'cocos_get_current_scene_meta': { + result = await scene('meta'); + if (!result.hasScene) { + result = { ...result, url: null, named: false }; + break; + } + const asset = await Editor.Message.request( + 'asset-db', + 'query-asset-info', + result.sceneAssetUuid, + ); + result = { ...result, url: asset?.url ?? null, named: !!asset }; + break; + } + case 'cocos_get_log_tail': + result = await paths.tail( + args.relativePath ?? 'temp/logs/project.log', + args.maxLines ?? 200, + ); + break; + case 'cocos_get_build_diagnostics': + result = await paths.buildLogs( + args.maxFiles ?? 10, + args.maxLinesPerFile ?? 200, + ); + break; + case 'cocos_diagnose': { + let sceneResult; + try { + sceneResult = await scene('meta'); + } catch (e) { + sceneResult = { error: e.message }; + } + const logs = await paths.tail( + 'temp/logs/project.log', + args.maxLogLines ?? 200, + ); + result = { + ready: true, + scene: sceneResult, + logs, + errors: logs.lines.filter((v) => /error|exception|failed/i.test(v)), + health: sceneResult.error + ? 'error' + : logs.missing + ? 'unknown' + : 'connected', + }; + break; + } + case 'cocos_list_assets': + result = await assets.list(args); + break; + case 'cocos_get_prefab_info': + result = await assets.prefab(args.prefabPath); + break; + case 'cocos_save_scene': + result = await save(args.path); + break; + case 'cocos_preview_debug_start': + result = await preview.start(args); + break; + case 'cocos_preview_debug_read': + result = preview.read(args); + break; + case 'cocos_preview_debug_capture': + result = await preview.capture(); + break; + case 'cocos_preview_debug_stop': + result = await preview.stop(); + break; + case 'cocos_mcp_undo_last': { + result = await scene(operation, args); + if (result.saved) result.persisted = await save(); + return result; + } + case 'cocos_apply_ui_spec': + case 'cocos_create_ui_shape': + case 'cocos_create_ui_label': + case 'cocos_create_ui_sprite': + case 'cocos_create_ui_button': { + const nodes = uiSpecs(operation, args); + validateUi(nodes); + await scene('cocos_inspect_node', { nid: args.parentNid }); + // 未命名场景先确定保存身份,避免事务完成后弹出“另存为”阻塞。 + if (args.save !== false) { + const meta = await scene('meta'); + if ( + !(await Editor.Message.request( + 'asset-db', + 'query-asset-info', + meta.sceneAssetUuid, + )) + ) + await save(); + } + await prepareUi(nodes); + const uuids = new Set(); + const collect = (list) => { + for (const node of list) { + if (node.assetUuid) uuids.add(node.assetUuid); + if (node.children) collect(node.children); + } + }; + collect(nodes); + await scene('preload-assets', { uuids: [...uuids] }); + result = await scene('cocos_apply_ui_spec', { + parentNid: args.parentNid, + nodes, + }); + if (result.status === 'completed' && args.save !== false) { + try { + result.saved = await save(); + } catch (e) { + try { + await scene('rollback-last'); + await save(); + return { + status: 'failed', + changed: false, + verified: false, + rolledBack: true, + error: e.message, + }; + } catch (rollback) { + return { + status: 'needs-reconciliation', + changed: true, + verified: false, + retryAllowed: false, + error: e.message, + rollbackError: rollback.message, + }; + } + } + } + return result; + } + case 'cocos_instantiate_prefab': { + const asset = await assets.info(args.prefabPath, 'prefab'); + await scene('preload-assets', { uuids: [asset.uuid] }); + return await scene(operation, { ...args, assetUuid: asset.uuid }); + } + case 'cocos_set_component_property': { + let value = args.value; + if ( + [ + 'spriteFrame', + 'font', + 'normalSprite', + 'pressedSprite', + 'hoverSprite', + 'disabledSprite', + ].includes(args.property) && + typeof value === 'string' + ) { + const uuid = value.startsWith('assets/') + ? args.property === 'font' + ? (await assets.info(value)).uuid + : await assets.spriteFrame(value) + : value; + value = { assetUuid: uuid }; + await scene('preload-assets', { uuids: [uuid] }); + } + return await scene(operation, { ...args, value }); + } + default: + result = await scene(operation, args); + } + if (result?.status) return result; + return { status: 'completed', result }; + } finally { + busy = false; + } + } + return { execute, dispose: preview.stop }; +}; diff --git a/plugins/agc-cocos-editor/src/operations/operations.test.cjs b/plugins/agc-cocos-editor/src/operations/operations.test.cjs new file mode 100644 index 000000000..79e36872c --- /dev/null +++ b/plugins/agc-cocos-editor/src/operations/operations.test.cjs @@ -0,0 +1,222 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const validate = require('./validate.cjs'); +const catalog = require('./catalog.json'); +const schema = (name) => catalog.find((t) => t.name === name).inputSchema; + +test('catalog accepts nested UI and rejects invalid or ambiguous parameters', async () => { + const { buildCocosOperationCode } = await import( + '../cocos-editor-operations.mjs' + ); + let nodes = [{ kind: 'label', text: '文字' }]; + for (let level = 1; level < 12; level++) + nodes = [{ kind: 'container', children: nodes }]; + const code = buildCocosOperationCode('cocos_apply_ui_spec', { + parentNid: 1, + nodes, + save: false, + }); + assert.ok(Buffer.byteLength(code) < 128 * 1024); + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + new AsyncFunction('Editor', 'require', code); + assert.throws( + () => + buildCocosOperationCode('cocos_delete_node', { nid: 1, confirm: false }), + /必须/, + ); + assert.throws( + () => + buildCocosOperationCode('cocos_set_node_name', { + nid: 1, + name: 'ok', + command: 'exec', + }), + /未知/, + ); + assert.throws(() => buildCocosOperationCode('cocos_unknown', {}), /不支持/); + assert.throws( + () => + validate(schema('cocos_set_node_transform'), { + nid: 1, + scale: { x: Infinity, y: 1, z: 1 }, + }), + /有限/, + ); +}); + +test('UI limits and duplicate IDs fail before touching the scene or assets', async () => { + const create = require('./main.cjs'); + let touched = 0; + const load = (name) => (name === 'validate.cjs' ? validate : () => ({})); + const requireMock = (name) => + name === 'electron' + ? { + webContents: { + getAllWebContents() { + touched++; + return []; + }, + }, + } + : require(name); + const runtime = create( + { Project: { path: os.tmpdir() } }, + requireMock, + load, + {}, + 'test', + [], + ); + const invoke = (nodes) => + runtime.execute( + 'cocos_apply_ui_spec', + { parentNid: 1, nodes, save: false }, + schema('cocos_apply_ui_spec'), + ); + await assert.rejects( + invoke([ + { kind: 'container', id: 'same' }, + { kind: 'label', id: 'same' }, + ]), + /重复/, + ); + await assert.rejects( + invoke(Array.from({ length: 33 }, () => ({ kind: 'button' }))), + /64/, + ); + let nodes = [{ kind: 'container' }]; + for (let i = 1; i < 13; i++) nodes = [{ kind: 'container', children: nodes }]; + await assert.rejects(invoke(nodes), /12/); + assert.equal(touched, 0); +}); + +test('log reads are bounded and do not follow project escapes or junctions', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-cocos-logs-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-cocos-outside-')); + try { + fs.mkdirSync(path.join(root, 'temp/logs'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'temp/logs/project.log'), + 'first\nerror: test\nlast', + ); + const paths = require('./paths.cjs')(root, require); + assert.deepEqual((await paths.tail('temp/logs/project.log', 2)).lines, [ + 'error: test', + 'last', + ]); + assert.equal((await paths.tail('temp/logs/missing.log')).missing, true); + await assert.rejects(paths.tail('../secret.log'), /只读取/); + assert.throws(() => paths.resolve('assets/../../outside'), /路径/); + fs.symlinkSync( + outside, + path.join(root, 'assets'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + assert.throws(() => paths.resolve('assets/leak.png'), /链接|junction/); + } finally { + // 测试自己创建的两个明确临时根;先移除链接,递归清理不跟随外部目录。 + if (fs.existsSync(path.join(root, 'assets'))) + fs.unlinkSync(path.join(root, 'assets')); + for (const target of [root, outside]) { + assert.equal(path.dirname(target), os.tmpdir()); + fs.rmSync(target, { recursive: true, force: true }); + } + } +}); + +function previewHarness({ stall = false } = {}) { + const windows = []; + class Window { + constructor() { + this.destroyed = false; + this.loaded = false; + this.calls = []; + const debuggerApi = new EventEmitter(); + debuggerApi.attach = () => this.calls.push('attach'); + debuggerApi.detach = () => this.calls.push('detach'); + debuggerApi.sendCommand = async (command) => { + assert.equal(this.loaded, true, 'renderer must exist before CDP'); + this.calls.push(command); + }; + this.webContents = new EventEmitter(); + this.webContents.debugger = debuggerApi; + this.webContents.setWindowOpenHandler = (fn) => (this.windowOpen = fn); + this.webContents.capturePage = async () => ({ + isEmpty: () => false, + resize: () => ({ + getSize: () => ({ width: 100, height: 60 }), + toPNG: () => Buffer.from('png'), + }), + getSize: () => ({ width: 100, height: 60 }), + }); + windows.push(this); + } + async loadURL(url) { + this.calls.push(url); + if (stall) return new Promise(() => {}); + this.loaded = true; + } + isDestroyed() { + return this.destroyed; + } + destroy() { + this.destroyed = true; + } + } + const electron = { + BrowserWindow: Window, + webContents: { + getAllWebContents: () => [ + { + getType: () => 'webview', + getURL: () => 'http://localhost:7456/preview', + }, + ], + }, + }; + return { + windows, + runtime: require('./preview.cjs')((name) => + name === 'electron' ? electron : require(name), + ), + }; +} +test('managed preview captures errors and only closes its own window', async () => { + const { runtime, windows } = previewHarness(); + await runtime.start({ url: 'http://localhost:7456/', timeoutMs: 100 }); + const window = windows[0]; + assert.equal(window.calls[0], 'about:blank'); + assert.equal(window.windowOpen().action, 'deny'); + window.webContents.debugger.emit('message', {}, 'Runtime.exceptionThrown', { + exceptionDetails: { text: 'script error' }, + }); + window.webContents.debugger.emit('message', {}, 'Network.loadingFailed', { + errorText: 'network failed', + }); + assert.equal(runtime.read({ errorsOnly: true }).events.length, 2); + assert.equal((await runtime.capture()).__image.mimeType, 'image/png'); + await runtime.stop(); + assert.equal(window.destroyed, true); + assert.throws(() => runtime.read({}), /没有/); + await assert.rejects( + runtime.start({ url: 'http://localhost:9999/' }), + /当前 Creator/, + ); + await assert.rejects( + runtime.start({ url: 'https://example.com' }), + /loopback/, + ); +}); +test('preview initialization timeout tears down even before renderer is ready', async () => { + const { runtime, windows } = previewHarness({ stall: true }); + await assert.rejects( + runtime.start({ url: 'http://localhost:7456/', timeoutMs: 10 }), + /超时/, + ); + assert.equal(windows[0].destroyed, true); +}); diff --git a/plugins/agc-cocos-editor/src/operations/paths.cjs b/plugins/agc-cocos-editor/src/operations/paths.cjs new file mode 100644 index 000000000..aba2c7040 --- /dev/null +++ b/plugins/agc-cocos-editor/src/operations/paths.cjs @@ -0,0 +1,139 @@ +'use strict'; + +module.exports = function projectPaths(root, require) { + const fs = require('node:fs'); + const path = require('node:path'); + root = fs.realpathSync(root); + function resolve(relative) { + if ( + typeof relative !== 'string' || + !relative || + relative.includes('\0') || + path.isAbsolute(relative) || + relative.includes(':') + ) + throw new Error('必须使用项目相对路径'); + const parts = relative.replaceAll('\\', '/').split('/'); + if ( + parts.some((p) => p === '..' || !p) || + ['.agent', '.git', '.codex', '.env'].some((p) => parts[0] === p) + ) + throw new Error('路径超出允许范围'); + const target = path.resolve(root, ...parts); + const rel = path.relative(root, target); + if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) + throw new Error('路径不属于项目'); + let cursor = root; + for (const part of parts) { + cursor = path.join(cursor, part); + try { + if (fs.lstatSync(cursor).isSymbolicLink()) + throw new Error('不允许符号链接或 junction'); + } catch (e) { + if (e.code === 'ENOENT') break; + throw e; + } + } + return target; + } + function assetUrl(relative) { + if (!relative?.startsWith('assets/')) + throw new Error('资源路径必须位于 assets/'); + resolve(relative); + return 'db://' + relative; + } + async function tail(relative, maxLines = 200) { + if ( + !/^(temp\/logs\/|temp\/builder\/|build\/)/.test(relative) || + !/\.(log|txt)$/i.test(relative) + ) + throw new Error('只读取 temp/logs、temp/builder 或 build 下日志'); + const file = resolve(relative); + let handle; + try { + handle = await fs.promises.open(file, 'r'); + } catch (e) { + if (e.code === 'ENOENT') + return { path: relative, missing: true, lines: [] }; + throw e; + } + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new Error('日志不是普通文件'); + const size = Math.min(stat.size, 256 * 1024); + const buffer = Buffer.alloc(size); + await handle.read(buffer, 0, size, stat.size - size); + const lines = buffer.toString('utf8').split(/\r?\n/); + if (stat.size > size) lines.shift(); + return { + path: relative, + missing: false, + truncated: stat.size > size || lines.length > maxLines, + lines: lines.slice(-maxLines), + }; + } finally { + await handle.close(); + } + } + async function buildLogs(maxFiles, maxLines) { + const files = []; + let visited = 0; + async function scan(relative, depth = 0) { + if (depth > 5 || visited >= 3000) return; + let entries; + try { + entries = await fs.promises.readdir(resolve(relative), { + withFileTypes: true, + }); + } catch (e) { + if (e.code === 'ENOENT') return; + throw e; + } + for (const entry of entries) { + if (++visited > 3000) break; + if (entry.isSymbolicLink()) continue; + const next = relative + '/' + entry.name; + if (entry.isDirectory()) await scan(next, depth + 1); + else if (entry.isFile() && /\.(log|txt)$/i.test(entry.name)) { + const stat = await fs.promises.stat(resolve(next)); + files.push({ path: next, mtime: stat.mtimeMs }); + } + } + } + await scan('temp/logs'); + await scan('temp/builder'); + await scan('build'); + files.sort((a, b) => b.mtime - a.mtime); + const logs = []; + let remaining = 384 * 1024; + for (const file of files.slice(0, maxFiles)) { + if (remaining <= 0) break; + const log = await tail(file.path, maxLines); + const selected = []; + for (const line of log.lines) { + const bytes = Buffer.byteLength(line); + if (bytes > remaining) { + log.truncated = true; + break; + } + remaining -= bytes; + selected.push(line); + } + log.lines = selected; + logs.push(log); + } + return { + files: logs, + scannedEntries: visited, + truncated: visited >= 3000 || files.length > maxFiles, + diagnostics: logs + .flatMap((f) => + f.lines + .filter((l) => /error|exception|failed|warning/i.test(l)) + .map((message) => ({ path: f.path, message })), + ) + .slice(0, 500), + }; + } + return { resolve, assetUrl, tail, buildLogs }; +}; diff --git a/plugins/agc-cocos-editor/src/operations/preview.cjs b/plugins/agc-cocos-editor/src/operations/preview.cjs new file mode 100644 index 000000000..225246991 --- /dev/null +++ b/plugins/agc-cocos-editor/src/operations/preview.cjs @@ -0,0 +1,182 @@ +'use strict'; + +module.exports = function previewOperations(require) { + const { BrowserWindow, webContents } = require('electron'); + let current = null; + function eventsPush(event) { + if (!current) return; + current.events.push({ at: new Date().toISOString(), ...event }); + if (current.events.length > 1000) current.events.shift(); + } + function active() { + if (!current || current.window.isDestroyed()) + throw new Error('没有插件托管的预览,请先调用 cocos_preview_debug_start'); + return current; + } + function checkUrl(url) { + const u = new URL(url); + if ( + !['http:', 'https:'].includes(u.protocol) || + !['localhost', '127.0.0.1', '[::1]'].includes(u.hostname) || + u.username || + u.password + ) + throw new Error('预览必须是当前项目的 loopback HTTP 地址'); + const candidates = webContents + .getAllWebContents() + .filter((w) => w.getType() === 'webview') + .map((w) => w.getURL()); + const matches = candidates.some((candidate) => { + try { + const c = new URL(candidate); + return ( + c.protocol === u.protocol && + c.port === u.port && + ['localhost', '127.0.0.1', '[::1]'].includes(c.hostname) + ); + } catch { + return false; + } + }); + if (!matches) + throw new Error('地址端口不属于当前 Creator 已加载的项目预览'); + return u; + } + async function stop() { + if (!current) return { stopped: false }; + const item = current; + current = null; + if (!item.window.isDestroyed()) { + try { + item.window.webContents.debugger.detach(); + } catch { + // 窗口可能已经自行断开调试连接,仍继续关闭自有窗口。 + } + item.window.destroy(); + } + return { stopped: true, eventCount: item.events.length }; + } + async function start(args) { + const url = checkUrl(args.url); + await stop(); + const window = new BrowserWindow({ + width: 1100, + height: 760, + title: 'Cocos Preview · AGC', + show: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + partition: 'agc-cocos-preview-' + Date.now(), + }, + }); + current = { window, events: [], url: url.href }; + const wc = window.webContents; + const allowed = (target) => { + try { + const u = new URL(target); + return u.origin === url.origin; + } catch { + return false; + } + }; + wc.setWindowOpenHandler(() => ({ action: 'deny' })); + wc.on('will-navigate', (event, target) => { + if (!allowed(target)) event.preventDefault(); + }); + wc.on('will-redirect', (event, target) => { + if (!allowed(target)) event.preventDefault(); + }); + wc.on('console-message', (_event, level, message, line, sourceId) => + eventsPush({ kind: 'console', level, message, line, sourceId }), + ); + wc.on('did-fail-load', (_event, code, message, failedUrl) => + eventsPush({ + kind: 'load-error', + level: 3, + code, + message, + url: failedUrl, + }), + ); + wc.on('render-process-gone', (_event, details) => + eventsPush({ kind: 'renderer-error', level: 3, message: details.reason }), + ); + let timer; + try { + const initialize = async () => { + // 新 BrowserWindow 尚无 renderer target;先创建 about:blank 文档再启用 CDP。 + await window.loadURL('about:blank'); + wc.debugger.attach('1.3'); + wc.debugger.on('message', (_event, method, params) => { + if (method === 'Runtime.exceptionThrown') + eventsPush({ + kind: 'exception', + level: 3, + message: + params.exceptionDetails?.exception?.description || + params.exceptionDetails?.text, + details: params.exceptionDetails, + }); + if (method === 'Network.loadingFailed') + eventsPush({ kind: 'network-error', level: 3, ...params }); + if ( + method === 'Network.responseReceived' && + params.response?.status >= 400 + ) + eventsPush({ + kind: 'http-error', + level: 3, + url: params.response.url, + status: params.response.status, + }); + }); + await wc.debugger.sendCommand('Runtime.enable'); + await wc.debugger.sendCommand('Network.enable'); + await window.loadURL(url.href); + }; + await Promise.race([ + initialize(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('预览初始化或加载超时')), + args.timeoutMs ?? 15000, + ); + }), + ]); + } catch (error) { + await stop(); + throw error; + } finally { + clearTimeout(timer); + } + return { started: true, url: url.href, browser: 'embedded-chromium' }; + } + function read(args) { + const item = active(); + const events = item.events.filter((e) => !args.errorsOnly || e.level >= 2); + return { + url: item.url, + events: events.slice(-(args.maxEvents ?? 100)), + truncated: events.length > (args.maxEvents ?? 100), + }; + } + async function capture() { + const item = active(), + image = await item.window.webContents.capturePage(); + if (image.isEmpty()) throw new Error('预览截图为空'); + const small = image.resize({ + width: Math.min(1024, image.getSize().width), + }); + const data = small.toPNG().toString('base64'); + if (data.length > 1500000) throw new Error('预览截图超过回执大小上限'); + return { + url: item.url, + width: small.getSize().width, + height: small.getSize().height, + __image: { data, mimeType: 'image/png' }, + }; + } + return { start, read, capture, stop }; +}; diff --git a/plugins/agc-cocos-editor/src/operations/scene.cjs b/plugins/agc-cocos-editor/src/operations/scene.cjs new file mode 100644 index 000000000..1576e3686 --- /dev/null +++ b/plugins/agc-cocos-editor/src/operations/scene.cjs @@ -0,0 +1,877 @@ +'use strict'; + +module.exports = function createSceneRuntime(cc, cce, _Editor) { + let epoch = ''; + let nextNid = 1; + const ids = new Map(); + const reverse = new Map(); + const history = []; + let historyBytes = 0; + const MAX_HISTORY_BYTES = 16 * 1024 * 1024; + function facade() { + const f = cce?.SceneFacadeManager?.getCurrentFacade(); + if ( + !f || + !f._sceneProxy?.serialize || + (f.modeName !== 'general' && f.constructor?.name !== 'GeneralSceneFacade') + ) { + throw new Error('当前编辑模式不支持场景编辑;请打开普通场景'); + } + return f; + } + function scene() { + const root = cc.director.getScene(); + if (!root) throw new Error('当前没有打开场景'); + if (epoch !== root.uuid) { + epoch = root.uuid; + ids.clear(); + reverse.clear(); + history.length = 0; + historyBytes = 0; + } + return root; + } + function nid(node) { + if (!reverse.has(node.uuid)) { + const id = nextNid++; + ids.set(id, node.uuid); + reverse.set(node.uuid, id); + } + return reverse.get(node.uuid); + } + function walk(root, fn, depth = 0) { + if (depth > 512) throw new Error('场景层级过深'); + fn(root, depth); + for (const child of root.children) walk(child, fn, depth + 1); + } + function find(ref) { + const root = scene(); + const uuid = typeof ref === 'number' ? ids.get(ref) : ref; + if (!uuid) throw new Error('NID 已失效,请重新查询场景'); + let found; + walk(root, (n) => { + if (n.uuid === uuid) found = n; + }); + if (!found) throw new Error('节点不存在于当前场景'); + return found; + } + const typeName = (c) => cc.js.getClassName(c.constructor); + const components = (n) => n.getComponents(cc.Component); + function component(n, index, expected) { + const c = components(n)[index]; + if (!c) throw new Error('组件索引不存在'); + if ( + expected && + typeName(c).replace(/^cc\./, '') !== expected.replace(/^cc\./, '') + ) + throw new Error('组件类型已变化'); + return c; + } + function plain(value, depth = 0) { + if (value == null || ['string', 'boolean', 'number'].includes(typeof value)) + return value; + if (depth > 3) return '[depth-limit]'; + if (value instanceof cc.Node) return { uuid: value.uuid }; + if (value instanceof cc.Asset) + return { uuid: value._uuid, type: cc.js.getClassName(value.constructor) }; + if (value instanceof cc.Component) + return { uuid: value.uuid, type: typeName(value) }; + if (Array.isArray(value)) + return value.slice(0, 64).map((v) => plain(v, depth + 1)); + const keys = ['x', 'y', 'z', 'w', 'r', 'g', 'b', 'a', 'width', 'height']; + const result = {}; + for (const key of keys) + if (typeof value[key] === 'number') result[key] = value[key]; + return Object.keys(result).length ? result : '[object]'; + } + function inspect(n) { + return { + nid: nid(n), + uuid: n.uuid, + name: n.name, + parentUuid: n.parent?.uuid ?? null, + active: n.active, + position: plain(n.position), + rotationEuler: plain(n.eulerAngles), + scale: plain(n.scale), + siblingIndex: n.getSiblingIndex(), + children: n.children.map((child) => ({ + nid: nid(child), + uuid: child.uuid, + name: child.name, + })), + components: components(n).map((c, index) => { + const properties = {}; + for (const key of [ + ...new Set([ + 'enabled', + ...(c.constructor.__props__ || []), + ...(c instanceof cc.UITransform ? ['width', 'height'] : []), + ]), + ].slice(0, 128)) { + if (key.startsWith('_') || ['node', 'name'].includes(key)) continue; + try { + properties[key] = plain(c[key]); + } catch { + // 无法读取的组件属性不进入检查摘要。 + } + } + return { index, uuid: c.uuid, type: typeName(c), properties }; + }), + }; + } + function serialize() { + const result = facade()._sceneProxy.serialize(true); + if (typeof result !== 'string' || result.length > 2 * 1024 * 1024) + throw new Error('场景事务快照超出 2 MiB 上限'); + return result; + } + function snapshotDifference(before, after) { + const differences = []; + const visit = (a, b, path) => { + if (differences.length >= 3 || JSON.stringify(a) === JSON.stringify(b)) + return; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') { + differences.push( + path + + ': ' + + JSON.stringify(a)?.slice(0, 80) + + ' -> ' + + JSON.stringify(b)?.slice(0, 80), + ); + return; + } + for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) + visit(a[key], b[key], path + '.' + key); + }; + visit(JSON.parse(before), JSON.parse(after), 'scene'); + return differences.join('; '); + } + async function restore(snapshot) { + const f = facade(); + const rootId = scene().uuid; + if (!(await f._sceneProxy._loadScene(rootId, snapshot))) + throw new Error('场景恢复失败'); + await f._sceneProxy._afterLoadScene(); + f.resetUndo(); + if (serialize() !== snapshot) throw new Error('场景恢复后回读不匹配'); + } + function remember(entry) { + const size = entry.before.length + entry.after.length; + while ( + history.length && + (historyBytes + size > MAX_HISTORY_BYTES || history.length >= 32) + ) { + const removed = history.shift(); + historyBytes -= removed.before.length + removed.after.length; + } + history.push(entry); + historyBytes += size; + } + async function transaction(name, fn) { + scene(); + const before = serialize(); + const f = facade(); + const recordingNodes = []; + walk(scene(), (node) => recordingNodes.push(node.uuid)); + const recording = f.beginRecording(recordingNodes); + try { + const result = await fn(); + await settle(); + if (result.verify) { + result.verify(); + delete result.verify; + } + await f.endRecording(recording); + const after = serialize(); + const changed = before !== after; + if (changed) + remember({ + name, + sceneUuid: scene().uuid, + before, + after, + saved: false, + }); + return { + status: 'completed', + changed, + verified: true, + undoAvailable: changed, + result, + }; + } catch (error) { + await f.cancelRecording(recording); + let rollbackError = null; + try { + if (serialize() !== before) await restore(before); + } catch (e) { + rollbackError = e.message; + } + return { + status: rollbackError ? 'needs-reconciliation' : 'failed', + changed: !!rollbackError, + verified: false, + undoAvailable: false, + rolledBack: !rollbackError, + retryAllowed: false, + error: error.message, + rollbackError, + }; + } + } + const equal = (a, b) => JSON.stringify(plain(a)) === JSON.stringify(plain(b)); + function check(ok, message) { + if (!ok) throw new Error(message); + } + function name(value) { + check( + typeof value === 'string' && + value.trim().length > 0 && + !Array.from(value).some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }), + '节点名无效', + ); + return value.trim(); + } + async function newNode(parent, nodeName, assetUuid) { + const uuid = await facade().createNode({ + parent: parent.uuid, + name: nodeName, + assetUuid, + }); + const node = find(typeof uuid === 'string' ? uuid : uuid?.uuid); + check(node.parent === parent, '节点父级回读失败'); + return node; + } + function ctor(componentType) { + const c = + cc.js.getClassByName(componentType) || + cc.js.getClassByName('cc.' + componentType) || + cc[componentType.replace(/^cc\./, '')]; + check( + typeof c === 'function' && cc.js.isChildClassOf(c, cc.Component), + '组件类型不存在或不是 Component', + ); + return c; + } + async function add(n, componentType) { + const C = ctor(componentType); + const prior = new Set(components(n).map((c) => c.uuid)); + facade().createComponent({ uuid: n.uuid, component: cc.js.getClassId(C) }); + const created = components(n).find( + (c) => !prior.has(c.uuid) && c instanceof C, + ); + check(created, '组件未创建(检查重复组件和依赖约束)'); + return created; + } + async function ensure(n, componentType) { + const C = ctor(componentType); + return n.getComponent(C) || (await add(n, componentType)); + } + function loadAsset(uuid) { + const cached = cc.assetManager.assets.get(uuid); + if (cached) return Promise.resolve(cached); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('资源加载超时:' + uuid)), + 1000, + ); + cc.assetManager.loadAny(uuid, (err, asset) => { + clearTimeout(timer); + err ? reject(err) : resolve(asset); + }); + }); + } + function convert(current, value, owner, property) { + if (current instanceof cc.Color) { + if (typeof value === 'string') { + check(/^#[\da-f]{6}([\da-f]{2})?$/i.test(value), '颜色格式错误'); + const v = value.slice(1); + return new cc.Color( + parseInt(v.slice(0, 2), 16), + parseInt(v.slice(2, 4), 16), + parseInt(v.slice(4, 6), 16), + v.length === 8 ? parseInt(v.slice(6, 8), 16) : 255, + ); + } + return new cc.Color(value.r, value.g, value.b, value.a ?? 255); + } + if (current instanceof cc.Vec3) + return new cc.Vec3(value.x, value.y, value.z ?? current.z); + if (current instanceof cc.Vec2) return new cc.Vec2(value.x, value.y); + if (current instanceof cc.Size) + return new cc.Size(value.width, value.height); + if (typeof current === 'number' && typeof value === 'string') { + const attributes = cc.Class.Attr.getClassAttrs(owner.constructor); + const list = attributes[property + '$_$enumList']; + const option = list?.find( + (o) => o.name.toLowerCase() === value.toLowerCase(), + ); + check(option, '枚举值不存在'); + return option.value; + } + if (current != null) + check(typeof current === typeof value, '属性值类型不匹配'); + return value; + } + async function setComponent(n, index, property, value) { + check( + !property.includes('.') && + ![ + '__proto__', + 'constructor', + 'prototype', + 'node', + 'uuid', + '_id', + 'name', + ].includes(property) && + !property.startsWith('_'), + '属性不允许', + ); + const c = component(n, index); + const allowed = new Set([ + 'enabled', + ...(c.constructor.__props__ || []), + ...(c instanceof cc.UITransform ? ['width', 'height'] : []), + ]); + check(allowed.has(property), '属性不在组件可序列化属性中'); + let next; + if (value && typeof value === 'object' && value.assetUuid) { + next = await loadAsset(value.assetUuid); + const expected = cc.Class.Attr.getClassAttrs(c.constructor)[ + property + '$_$ctor' + ]; + if (typeof expected === 'function') + check(next instanceof expected, '资源类型与属性声明不匹配'); + } else next = convert(c[property], value, c, property); + if (next && typeof next === 'object') + check( + next instanceof cc.Asset || + next instanceof cc.Color || + next instanceof cc.Vec2 || + next instanceof cc.Vec3 || + next instanceof cc.Size, + '不支持该对象属性类型', + ); + c[property] = next; + check(equal(c[property], next), '组件属性回读失败'); + return { node: inspect(n), property, value: plain(c[property]) }; + } + async function settle() { + // Label 的派生 actualFontSize 在渲染时才更新;先刷新,避免稍后被误判为用户编辑。 + walk(scene(), (n) => n.getComponent(cc.Label)?.updateRenderData(true)); + // Layout/Widget 的更新在运行态读取前完成,父布局优先处理,再处理子级。 + walk(scene(), (n) => n.getComponent(cc.Layout)?.updateLayout(true)); + walk(scene(), (n) => n.getComponent(cc.Widget)?.updateAlignment()); + walk(scene(), (n) => n.getComponent(cc.Label)?.updateRenderData(true)); + } + async function uiNode(parent, spec, receipts) { + const node = await newNode(parent, name(spec.name || spec.id || spec.kind)); + node.layer = parent.layer; + if (spec.active !== undefined) node.active = spec.active; + if (spec.position) + node.setPosition(spec.position.x, spec.position.y, spec.position.z); + const transform = await ensure(node, 'UITransform'); + const size = + spec.size || + (spec.kind === 'button' + ? { width: 180, height: 56 } + : spec.kind === 'label' + ? { width: 220, height: 48 } + : { width: 100, height: 100 }); + transform.setContentSize(size.width, size.height); + check( + equal(transform.contentSize, new cc.Size(size.width, size.height)), + 'UI 尺寸回读失败', + ); + if (['shape', 'sprite', 'button'].includes(spec.kind)) { + const sprite = await ensure(node, 'Sprite'); + sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; + const frame = await loadAsset(spec.assetUuid); + check(frame instanceof cc.SpriteFrame, '资源不是 SpriteFrame'); + sprite.spriteFrame = frame; + sprite.color = convert( + sprite.color, + spec.color || '#FFFFFFFF', + sprite, + 'color', + ); + if (spec.nineSlice || spec.kind === 'button') + sprite.type = cc.Sprite.Type.SLICED; + check( + sprite.spriteFrame?._uuid === spec.assetUuid, + 'SpriteFrame 回读失败', + ); + } + if (spec.kind === 'label') { + const label = await ensure(node, 'Label'); + label.overflow = cc.Label.Overflow.CLAMP; + label.string = spec.text ?? ''; + label.fontSize = spec.fontSize ?? 28; + label.lineHeight = spec.lineHeight ?? spec.fontSize ?? 32; + label.horizontalAlign = { left: 0, center: 1, right: 2 }[ + spec.horizontalAlign || 'center' + ]; + label.verticalAlign = { top: 0, center: 1, bottom: 2 }[ + spec.verticalAlign || 'center' + ]; + label.color = convert( + label.color, + spec.color || '#FFFFFFFF', + label, + 'color', + ); + check( + label.string === (spec.text ?? '') && + label.fontSize === (spec.fontSize ?? 28), + 'Label 回读失败', + ); + } + if (spec.kind === 'button') { + const button = await ensure(node, 'Button'); + button.interactable = spec.interactable !== false; + button.target = node; + await uiNode( + node, + { + kind: 'label', + name: 'Label', + text: spec.buttonLabel ?? 'Button', + fontSize: spec.fontSize, + size, + widget: { left: 0, right: 0, top: 0, bottom: 0 }, + }, + receipts, + ); + check(button.target === node, 'Button target 回读失败'); + } + if (spec.layout) { + const layout = await ensure(node, 'Layout'), + v = spec.layout; + layout.type = { horizontal: 1, vertical: 2, grid: 3 }[v.type]; + layout.resizeMode = { none: 0, container: 1, children: 2 }[ + v.resizeMode || 'none' + ]; + for (const k of [ + 'spacingX', + 'spacingY', + 'paddingLeft', + 'paddingRight', + 'paddingTop', + 'paddingBottom', + 'constraintNum', + ]) + if (v[k] !== undefined) layout[k] = v[k]; + if (v.cellSize) + layout.cellSize = new cc.Size(v.cellSize.width, v.cellSize.height); + if (v.constraint) + layout.constraint = { none: 0, 'fixed-row': 1, 'fixed-column': 2 }[ + v.constraint + ]; + } + if (spec.widget) { + const widget = await ensure(node, 'Widget'); + for (const [key, val] of Object.entries(spec.widget)) { + const flag = + { + horizontalCenter: 'isAlignHorizontalCenter', + verticalCenter: 'isAlignVerticalCenter', + }[key] || 'isAlign' + key[0].toUpperCase() + key.slice(1); + widget[flag] = true; + widget[key] = val; + } + widget.alignMode = cc.Widget.AlignMode.ALWAYS; + } + for (const child of spec.children || []) + await uiNode(node, child, receipts); + receipts.push({ + id: spec.id ?? null, + nid: nid(node), + uuid: node.uuid, + kind: spec.kind, + name: node.name, + spec, + parentUuid: parent.uuid, + }); + return node; + } + function verifyUi(receipts) { + for (const receipt of receipts) { + const node = find(receipt.uuid), + spec = receipt.spec; + check(node.parent?.uuid === receipt.parentUuid, 'UI 父子关系回读失败'); + check(node.active === (spec.active ?? true), 'UI active 回读失败'); + const t = node.getComponent(cc.UITransform); + check( + t && + Number.isFinite(t.width) && + Number.isFinite(t.height) && + t.width >= 0 && + t.height >= 0, + 'UI 运行态尺寸无效', + ); + const managedByParent = + node.parent.getComponent(cc.Layout)?.resizeMode === + cc.Layout.ResizeMode.CHILDREN; + if (spec.size && !managedByParent) { + if (spec.widget?.left === undefined || spec.widget?.right === undefined) + check( + Math.abs(t.width - spec.size.width) < 0.05, + 'UI 宽度与声明不符', + ); + if (spec.widget?.top === undefined || spec.widget?.bottom === undefined) + check( + Math.abs(t.height - spec.size.height) < 0.05, + 'UI 高度与声明不符', + ); + } + if (['shape', 'sprite', 'button'].includes(spec.kind)) { + const sprite = node.getComponent(cc.Sprite); + check( + sprite?.spriteFrame?._uuid === spec.assetUuid, + 'UI Sprite 资源不符', + ); + check( + equal( + sprite.color, + convert(sprite.color, spec.color || '#FFFFFFFF', sprite, 'color'), + ), + 'UI Sprite 颜色不符', + ); + if (spec.nineSlice || spec.kind === 'button') + check(sprite.type === cc.Sprite.Type.SLICED, '九宫格未生效'); + } + if (spec.kind === 'label') { + const label = node.getComponent(cc.Label); + check( + label?.string === (spec.text ?? '') && + label.fontSize === (spec.fontSize ?? 28), + 'Label 内容或字体不符', + ); + } + if (spec.kind === 'button') { + const button = node.getComponent(cc.Button); + check( + button?.target === node && + button.interactable === (spec.interactable !== false), + 'Button 配置不符', + ); + } + if (spec.layout) + check( + node.getComponent(cc.Layout)?.type === + { horizontal: 1, vertical: 2, grid: 3 }[spec.layout.type], + 'Layout 类型不符', + ); + if (spec.widget) { + const widget = node.getComponent(cc.Widget); + check(widget, 'Widget 缺失'); + for (const [key, value] of Object.entries(spec.widget)) + check(widget[key] === value, 'Widget ' + key + ' 不符'); + } + delete receipt.spec; + delete receipt.parentUuid; + } + } + async function mutate(operation, args) { + const n = () => find(args.nid); + switch (operation) { + case 'cocos_set_node_active': { + const v = n(); + v.active = args.active; + check(v.active === args.active, 'active 回读失败'); + return inspect(v); + } + case 'cocos_set_node_name': { + const v = n(); + v.name = name(args.name); + check(v.name === args.name.trim(), 'name 回读失败'); + return inspect(v); + } + case 'cocos_set_node_transform': { + const v = n(); + check( + args.position || args.rotationEuler || args.scale, + '至少指定一个变换', + ); + if (args.position) { + v.setPosition(args.position.x, args.position.y, args.position.z); + check(equal(v.position, args.position), 'position 回读失败'); + } + if (args.rotationEuler) { + const { x, y, z } = args.rotationEuler; + v.setRotationFromEuler(x, y, z); + const expected = cc.Quat.fromEuler(new cc.Quat(), x, y, z); + check( + Math.abs(Math.abs(cc.Quat.dot(v.rotation, expected)) - 1) < 0.001, + 'rotation 回读失败', + ); + } + if (args.scale) { + v.setScale(args.scale.x, args.scale.y, args.scale.z); + check(equal(v.scale, args.scale), 'scale 回读失败'); + } + return inspect(v); + } + case 'cocos_create_node': + return inspect(await newNode(find(args.parentNid), name(args.name))); + case 'cocos_instantiate_prefab': + return inspect( + await newNode(find(args.parentNid), 'Prefab', args.assetUuid), + ); + case 'cocos_delete_node': { + const v = n(), + uuid = v.uuid; + check(args.confirm === true, '必须 confirm:true'); + check(v !== scene(), '不能删除场景根'); + check(args.cascade || !v.children.length, '删除子树必须 cascade:true'); + await facade().removeNode({ uuid: [uuid] }); + let exists = false; + walk(scene(), (node) => { + if (node.uuid === uuid) exists = true; + }); + check(!exists, '节点删除未生效'); + return { deletedUuid: uuid }; + } + case 'cocos_reparent_node': { + const child = find(args.childNid), + parent = find(args.parentNid); + check(child !== scene(), '不能移动场景根'); + for (let v = parent; v; v = v.parent) + check(v !== child, '父级会形成循环'); + const position = child.worldPosition.clone(), + rotation = child.worldRotation.clone(), + scale = child.worldScale.clone(); + child.setParent(parent, args.keepWorldTransform !== false); + check(child.parent === parent, '父级回读失败'); + if (args.keepWorldTransform !== false) { + check( + cc.Vec3.equals(child.worldPosition, position, 0.001) && + cc.Quat.equals(child.worldRotation, rotation, 0.001) && + cc.Vec3.equals(child.worldScale, scale, 0.001), + '世界变换回读失败', + ); + } + return inspect(child); + } + case 'cocos_set_node_sibling_index': { + const v = n(); + check(v.parent, '不能移动场景根'); + const index = Math.min(args.index, v.parent.children.length - 1); + v.setSiblingIndex(index); + check(v.getSiblingIndex() === index, '兄弟顺序回读失败'); + return inspect(v); + } + case 'cocos_duplicate_node': { + const v = n(); + check(v.parent, '不能复制场景根'); + const clone = cc.instantiate(v); + clone.parent = v.parent; + clone.name = v.name; + check( + clone.uuid !== v.uuid && + clone.children.length === v.children.length && + components(clone).length === components(v).length, + '复制回读失败', + ); + return inspect(clone); + } + case 'cocos_add_component': { + const v = n(), + created = await add(v, args.componentType); + return { + node: inspect(v), + componentIndex: components(v).indexOf(created), + componentUuid: created.uuid, + }; + } + case 'cocos_remove_component': { + const v = n(), + c = component(v, args.componentIndex, args.expectedComponentType), + uuid = c.uuid; + await facade().removeComponent({ uuid: c.uuid }); + // Creator 部分版本按 node UUID + index 接收;仅确认仍存在才报告失败,不重放删除。 + check(!components(v).some((x) => x.uuid === uuid), '组件删除未生效'); + return inspect(v); + } + case 'cocos_set_component_property': + return setComponent( + n(), + args.componentIndex, + args.property, + args.value, + ); + case 'cocos_apply_ui_spec': { + const parent = find(args.parentNid), + receipts = []; + for (const spec of args.nodes) await uiNode(parent, spec, receipts); + return { nodes: receipts, verify: () => verifyUi(receipts) }; + } + default: + throw new Error('未知场景 mutation'); + } + } + return { + async call(operation, args = {}) { + if (operation === 'meta' && !cc.director.getScene()) + return { + hasScene: false, + sceneAssetUuid: null, + name: null, + dirty: false, + }; + const root = scene(); + if (operation === 'meta') + return { + hasScene: true, + sceneAssetUuid: root.uuid, + name: root.name, + dirty: await facade().querySceneDirty(), + }; + if (operation === 'serialize') return serialize(); + if (operation === 'preload-assets') { + for (const uuid of args.uuids) { + let last; + for (let attempt = 0; attempt < 10; attempt++) { + try { + await loadAsset(uuid); + last = null; + break; + } catch (e) { + last = e; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + if (last) throw last; + } + return { loaded: args.uuids.length }; + } + if (operation === 'mark-saved') { + const f = facade(); + f._undoMgr.save(); + f.changeTitle(); + return true; + } + if (operation === 'save-receipt') { + if (history.length) { + history.at(-1).after = serialize(); + history.at(-1).saved = true; + } + return true; + } + if ( + operation === 'cocos_get_hierarchy' || + operation === 'cocos_search_nodes' + ) { + const include = (n) => + (args.includeInternal || !n.name.startsWith('__')) && + (args.includeEditorDecorations || + !/^(Editor Scene|gizmoRoot|GAME_VIEW)/i.test(n.name)); + if (operation === 'cocos_search_nodes') { + check( + args.nameSubstring?.trim() || args.componentFilter?.trim(), + '必须指定名称或组件过滤条件', + ); + const matches = []; + let truncated = false; + walk(root, (n) => { + if (!include(n)) return; + if ( + args.nameSubstring && + !n.name.toLowerCase().includes(args.nameSubstring.toLowerCase()) + ) + return; + if ( + args.componentFilter && + !components(n).some((c) => + typeName(c) + .toLowerCase() + .includes(args.componentFilter.toLowerCase()), + ) + ) + return; + if (matches.length >= (args.maxResults ?? 100)) { + truncated = true; + return; + } + matches.push(inspect(n)); + }); + return { sceneUuid: root.uuid, nodes: matches, truncated }; + } + let count = 0, + truncated = false; + const tree = (n, depth) => { + if (!include(n)) return null; + if (count >= (args.maxNodes ?? 1000)) { + truncated = true; + return null; + } + count++; + const result = { + nid: nid(n), + uuid: n.uuid, + name: n.name, + active: n.active, + parentUuid: n.parent?.uuid ?? null, + components: components(n).map(typeName), + children: [], + }; + if (depth < (args.maxDepth ?? 20)) + result.children = n.children + .map((c) => tree(c, depth + 1)) + .filter(Boolean); + else if (n.children.length) truncated = true; + return result; + }; + return { sceneUuid: root.uuid, tree: tree(root, 0), truncated }; + } + if (operation === 'cocos_inspect_node') return inspect(find(args.nid)); + if (operation === 'cocos_editor_undo') { + const before = serialize(); + const entry = history.at(-1); + await facade().undo(); + let after = serialize(); + if (entry && entry.after === before) { + if (after !== entry.before) await restore(entry.before); + after = serialize(); + check(after === entry.before, '撤销后与事务前状态不一致'); + history.pop(); + historyBytes -= entry.before.length + entry.after.length; + } + return { + status: 'completed', + changed: before !== after, + verified: true, + }; + } + if ( + operation === 'cocos_mcp_undo_last' || + operation === 'rollback-last' + ) { + const entry = history.at(-1); + check(entry, '没有可撤销的 MCP 编辑事务'); + const current = serialize(); + check( + entry.sceneUuid === root.uuid && current === entry.after, + '场景已有后续修改,拒绝覆盖;请先核对:' + + snapshotDifference(entry.after, current), + ); + await restore(entry.before); + history.pop(); + historyBytes -= entry.before.length + entry.after.length; + return { + status: 'completed', + changed: true, + verified: true, + undoneOperation: entry.name, + saved: entry.saved, + }; + } + return transaction(operation, () => mutate(operation, args)); + }, + }; +}; diff --git a/plugins/agc-cocos-editor/src/operations/validate.cjs b/plugins/agc-cocos-editor/src/operations/validate.cjs new file mode 100644 index 000000000..f4d8a8fea --- /dev/null +++ b/plugins/agc-cocos-editor/src/operations/validate.cjs @@ -0,0 +1,85 @@ +'use strict'; + +// 只使用目录中声明的 JSON Schema 子集;额外语义由操作在任何 mutation 前检查。 +module.exports = function validate( + schema, + value, + root = schema, + at = 'args', + depth = 0, +) { + if (depth > 96) throw new Error(at + ' 嵌套超过上限'); + if (schema.$ref) { + const target = schema.$ref + .split('/') + .slice(1) + .reduce((v, k) => v?.[k], root); + if (!target) throw new Error('未知 schema 引用'); + return validate(target, value, root, at, depth + 1); + } + if (schema.anyOf) { + for (const option of schema.anyOf) { + try { + validate(option, value, root, at, depth + 1); + return; + } catch { + // 当前分支不匹配时继续验证 anyOf 的下一个分支。 + } + } + throw new Error(at + ' 类型或值不符合要求'); + } + if (Object.hasOwn(schema, 'const') && value !== schema.const) + throw new Error(at + ' 必须为 ' + schema.const); + if (schema.enum && !schema.enum.includes(value)) + throw new Error(at + ' 不在允许值中'); + const type = schema.type; + if (type === 'object') { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error(at + ' 必须是对象'); + for (const key of schema.required || []) + if (!Object.hasOwn(value, key)) throw new Error(at + '.' + key + ' 必填'); + for (const [key, item] of Object.entries(value)) { + if (['__proto__', 'prototype', 'constructor'].includes(key)) + throw new Error(at + ' 属性不允许'); + if (!Object.hasOwn(schema.properties || {}, key)) { + if (schema.additionalProperties === false) + throw new Error(at + '.' + key + ' 是未知参数'); + } else + validate(schema.properties[key], item, root, at + '.' + key, depth + 1); + } + } else if (type === 'array') { + if (!Array.isArray(value)) throw new Error(at + ' 必须是数组'); + if ( + value.length < (schema.minItems ?? 0) || + value.length > (schema.maxItems ?? Infinity) + ) + throw new Error(at + ' 数量越界'); + for (const item of value) + validate(schema.items, item, root, at + '[]', depth + 1); + } else if (type === 'string') { + if (typeof value !== 'string' || value.includes('\0')) + throw new Error(at + ' 必须是无 NUL 的字符串'); + if ( + value.length < (schema.minLength ?? 0) || + value.length > (schema.maxLength ?? Infinity) + ) + throw new Error(at + ' 长度越界'); + if (schema.pattern && !new RegExp(schema.pattern).test(value)) + throw new Error(at + ' 格式错误'); + } else if (type === 'number' || type === 'integer') { + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + (type === 'integer' && !Number.isInteger(value)) + ) + throw new Error(at + ' 必须是有限数值'); + if ( + value < (schema.minimum ?? -Infinity) || + value > (schema.maximum ?? Infinity) + ) + throw new Error(at + ' 数值越界'); + } else if (type === 'boolean' && typeof value !== 'boolean') + throw new Error(at + ' 必须是布尔值'); + else if (type === 'null' && value !== null) + throw new Error(at + ' 必须是 null'); +};