From 3fc3399032af4f6a05bfff59f6e668f315231096 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:39:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20DotCraft=20Unity=20?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增内置 Unity 插件与自包含 Attach helper,固定上游版本并保留许可证 统一 Runner 执行归属、项目身份校验、回执确认和不确定结果阻断 接入 Unity 工程导入、Agent 工具目录及 Windows 构建分发 补齐插件测试、CI 执行入口和 Windows .NET 10 工具链检查 记录真实 Unity 连接、执行、重载恢复及 Runner 边界验证 --- .gitea/workflows/project-ci.yml | 26 +- .gitignore | 4 + .../scripts/build-release.mjs | 1 + .../scripts/build-release.test.mjs | 6 +- .../scripts/cargo-features.mjs | 2 +- .../scripts/cargo-features.test.mjs | 2 +- .../scripts/check-config.mjs | 1 - .../scripts/start-tauri-dev.mjs | 12 +- .../src-tauri/Cargo.lock | 11 + .../src-tauri/Cargo.toml | 2 + apps/ai-game-creator-shell/src-tauri/build.rs | 110 +++- .../src-tauri/src/agent/direct_runtime/mod.rs | 10 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 50 ++ .../src-tauri/src/agent/direct_tools_mcp.rs | 62 +- .../src/agent/runtime_actions/action_audit.rs | 2 +- .../agent/runtime_actions/action_execution.rs | 10 + .../agent/runtime_actions/parallel_ledger.rs | 1 + .../provider_request_builders.rs | 13 +- .../runtime_actions/provider_tool_plan.rs | 2 +- .../runtime_actions/tool_policy_snapshot.rs | 12 + .../src-tauri/src/agent/runtime_tools.rs | 2 + .../src/agent/runtime_tools/unity_editor.rs | 79 +++ .../src-tauri/src/agent_native_tools.rs | 32 +- .../src-tauri/src/builtin_plugins.rs | 96 +++- .../src-tauri/src/commands.rs | 18 + .../src-tauri/src/editor_adapters.rs | 419 ++++++++++++++ .../src-tauri/src/isolated_agent.rs | 2 + .../src-tauri/src/main.rs | 3 + .../src-tauri/src/plugin_host.rs | 403 +++++++++---- .../src-tauri/src/project/filesystem.rs | 10 + .../src-tauri/src/project/manifest.rs | 70 +++ .../src-tauri/src/runner.rs | 4 + .../src-tauri/src/runner/client.rs | 181 ++++++ .../src-tauri/src/runner/dispatch.rs | 99 +++- .../src-tauri/src/runner/endpoint.rs | 17 + .../src-tauri/src/runner/protocol.rs | 5 +- .../src-tauri/src/runner/server.rs | 1 + .../src-tauri/src/runner/tests.rs | 44 ++ .../src-tauri/src/tests/project.rs | 62 ++ apps/ai-game-creator-shell/src/App.tsx | 20 +- apps/ai-game-creator-shell/src/app/types.ts | 4 +- .../src/features/agent-runtime/model.ts | 3 +- .../features/app-shell/ProjectCreation.tsx | 6 +- .../src/features/app-shell/model.ts | 25 +- .../app-shell/useHomeProjectCreation.ts | 24 +- .../src/services/pluginHost.ts | 15 +- .../tests/pluginHost.test.ts | 62 +- .../tests/unityProjectOpen.test.tsx | 86 +++ docs/README.md | 1 + .../shared-memory/project-overview.md | 1 + ...方案】AGC Unity编辑器插件接入-2026-09-18.md | 93 +++ ...案】AGC通用插件宿主与编辑器适配-2026-09-09.md | 10 +- .../Jenkinsfile.ai-game-creator-shell-build | 9 +- .../Jenkinsfile.scheduled-revision-trigger | 2 +- package-lock.json | 18 + package.json | 9 +- plugins/README.md | 10 + .../dotnet/Agc.Unity.Attach.csproj | 17 + plugins/agc-unity-editor/dotnet/NOTICE | 20 + .../dotnet/THIRD-PARTY-NOTICES.txt | 18 + plugins/agc-unity-editor/dotnet/build.ps1 | 57 ++ .../dotnet/licenses/Roslyn-MIT.txt | 21 + .../dotnet/src/DiagnosticPaths.cs | 34 ++ .../dotnet/src/EditorBackend.cs | 55 ++ .../agc-unity-editor/dotnet/src/HelperHost.cs | 349 ++++++++++++ .../agc-unity-editor/dotnet/src/Program.cs | 18 + .../dotnet/src/ProjectIdentity.cs | 80 +++ .../dotnet/src/ProtocolServer.cs | 101 ++++ .../tests/Agc.Unity.Attach.Tests.csproj | 11 + .../agc-unity-editor/dotnet/tests/Program.cs | 338 +++++++++++ .../vendor/DotCraft.Unity.Attach.csproj | 20 + .../dotnet/vendor/Host/AttachAttempt.cs | 92 +++ .../dotnet/vendor/Host/AttachHandshake.cs | 32 ++ .../dotnet/vendor/Host/AttachProtocol.cs | 7 + .../dotnet/vendor/Host/AttachStorage.cs | 162 ++++++ .../dotnet/vendor/Host/BridgeClient.cs | 150 +++++ .../dotnet/vendor/Host/EditorDiscovery.cs | 31 + .../dotnet/vendor/Host/NativeInjector.cs | 252 ++++++++ .../vendor/Host/SnippetSourceBuilder.cs | 188 ++++++ .../dotnet/vendor/Host/TargetCompiler.cs | 35 ++ .../agc-unity-editor/dotnet/vendor/LICENSE | 201 +++++++ .../dotnet/vendor/Native/Bootstrap.cpp | 217 +++++++ .../dotnet/vendor/Payload/Bridge.cs | 500 ++++++++++++++++ .../dotnet/vendor/Payload/Entry.cs | 40 ++ .../dotnet/vendor/Shared/Dcu.cs | 538 ++++++++++++++++++ .../Shared/UnityContinuationScheduler.cs | 57 ++ .../vendor/Shared/UnityExecutionContext.cs | 91 +++ .../vendor/Shared/UnityJsonConverter.cs | 126 ++++ .../vendor/Shared/UnityValueNormalizer.cs | 116 ++++ .../dotnet/vendor/UnityAttachService.cs | 396 +++++++++++++ .../dotnet/vendor/upstream.json | 99 ++++ .../native/unity-editor-bridge/Cargo.lock | 222 ++++++++ .../native/unity-editor-bridge/Cargo.toml | 26 + .../native/unity-editor-bridge/src/lib.rs | 507 +++++++++++++++++ .../native/unity-editor-bridge/src/tests.rs | 152 +++++ .../unity-editor-bridge/src/transport.rs | 229 ++++++++ .../unity-editor-bridge/tests/live_unity.rs | 137 +++++ .../unity-editor-bridge/tests/protocol.rs | 132 +++++ .../tests/support/helper_fixture.rs | 45 ++ plugins/agc-unity-editor/package.json | 13 + plugins/agc-unity-editor/plugin.json | 20 + plugins/agc-unity-editor/src/entry.mjs | 281 +++++++++ plugins/agc-unity-editor/src/entry.test.mjs | 256 +++++++++ scripts/check-native-shells.mjs | 12 + scripts/check-npm-workspaces.mjs | 3 + scripts/check-npm-workspaces.test.mjs | 2 + scripts/check-production-ops-guardrails.mjs | 2 +- scripts/lint-staged-rustfmt-workspaces.mjs | 5 + scripts/lint-staged-rustfmt.test.ts | 4 +- scripts/project-ci-workflow.test.ts | 23 +- 110 files changed, 8610 insertions(+), 214 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs create mode 100644 apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx create mode 100644 docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md create mode 100644 plugins/agc-unity-editor/dotnet/Agc.Unity.Attach.csproj create mode 100644 plugins/agc-unity-editor/dotnet/NOTICE create mode 100644 plugins/agc-unity-editor/dotnet/THIRD-PARTY-NOTICES.txt create mode 100644 plugins/agc-unity-editor/dotnet/build.ps1 create mode 100644 plugins/agc-unity-editor/dotnet/licenses/Roslyn-MIT.txt create mode 100644 plugins/agc-unity-editor/dotnet/src/DiagnosticPaths.cs create mode 100644 plugins/agc-unity-editor/dotnet/src/EditorBackend.cs create mode 100644 plugins/agc-unity-editor/dotnet/src/HelperHost.cs create mode 100644 plugins/agc-unity-editor/dotnet/src/Program.cs create mode 100644 plugins/agc-unity-editor/dotnet/src/ProjectIdentity.cs create mode 100644 plugins/agc-unity-editor/dotnet/src/ProtocolServer.cs create mode 100644 plugins/agc-unity-editor/dotnet/tests/Agc.Unity.Attach.Tests.csproj create mode 100644 plugins/agc-unity-editor/dotnet/tests/Program.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/DotCraft.Unity.Attach.csproj create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Host/AttachAttempt.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Host/AttachHandshake.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Host/AttachProtocol.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Host/AttachStorage.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Host/BridgeClient.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Host/EditorDiscovery.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Host/NativeInjector.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Host/SnippetSourceBuilder.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Host/TargetCompiler.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/LICENSE create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Native/Bootstrap.cpp create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Payload/Bridge.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Payload/Entry.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Shared/Dcu.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Shared/UnityContinuationScheduler.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Shared/UnityExecutionContext.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Shared/UnityJsonConverter.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/Shared/UnityValueNormalizer.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/UnityAttachService.cs create mode 100644 plugins/agc-unity-editor/dotnet/vendor/upstream.json create mode 100644 plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.lock create mode 100644 plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml create mode 100644 plugins/agc-unity-editor/native/unity-editor-bridge/src/lib.rs create mode 100644 plugins/agc-unity-editor/native/unity-editor-bridge/src/tests.rs create mode 100644 plugins/agc-unity-editor/native/unity-editor-bridge/src/transport.rs create mode 100644 plugins/agc-unity-editor/native/unity-editor-bridge/tests/live_unity.rs create mode 100644 plugins/agc-unity-editor/native/unity-editor-bridge/tests/protocol.rs create mode 100644 plugins/agc-unity-editor/native/unity-editor-bridge/tests/support/helper_fixture.rs create mode 100644 plugins/agc-unity-editor/package.json create mode 100644 plugins/agc-unity-editor/plugin.json create mode 100644 plugins/agc-unity-editor/src/entry.mjs create mode 100644 plugins/agc-unity-editor/src/entry.test.mjs diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 5088f80b6..c38208380 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -221,8 +221,7 @@ jobs: - name: Run AI game creator shell agent-run smoke run: npm run check:native-shells:agc-rust-smoke - # AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate - # 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。 + # AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。 ai-game-creator-shell-rust-crates: name: AI game creator shell Rust crates runs-on: genarrative-ci @@ -263,13 +262,15 @@ jobs: # `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试 # 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红 # (见 #327 / PR #316 run 1950)。 - # 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: + # Cocos 插件也使用独立且未提交的锁文件,一并预热。 + # 这些 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: # 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内, # 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本 # 解析,不再触碰 registry index。 for manifest_path in \ server-rs/crates/agent-runtime-core/Cargo.toml \ - server-rs/crates/agent-runtime-orchestration/Cargo.toml; do + server-rs/crates/agent-runtime-orchestration/Cargo.toml \ + plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do for attempt in $(seq 1 5); do if cargo fetch \ --target x86_64-unknown-linux-gnu \ @@ -284,6 +285,23 @@ jobs: done done + - name: Prepare Unity plugin Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'Unity plugin Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + - name: Run AI game creator shell shared crate gates run: npm run check:native-shells:agc-rust-crates diff --git a/.gitignore b/.gitignore index 34e0fbde7..f66e67ded 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,10 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md /plugins/agc-cocos-editor/native/payload/ +/plugins/agc-unity-editor/dotnet/**/bin/ +/plugins/agc-unity-editor/dotnet/**/obj/ +/plugins/agc-unity-editor/dotnet/publish/ +/plugins/agc-unity-editor/dotnet/native-build/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index b1fc307f5..ace684157 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -105,6 +105,7 @@ export const agcReleasePathPatterns = [ 'packages/', 'server-rs/crates/', 'plugins/agc-cocos-editor/', + 'plugins/agc-unity-editor/', 'apps/desktop-shell/src-tauri/icons/', 'package.json', 'package-lock.json', diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 8cb9c0214..274f8892e 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -344,7 +344,11 @@ test('Windows remains the default and explicit Windows overrides macOS environme context, { spawn: (_binary, command) => { - assert.ok(command.includes('--features=cocos-editor-execute')); + assert.ok( + command.includes( + '--features=cocos-editor-execute,unity-editor-execute', + ), + ); assert.ok(command.includes('user-config.json')); const configIndex = command.lastIndexOf('--config'); const config = JSON.parse( diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index de476e0a7..b9282a77f 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -19,6 +19,6 @@ export function withDefaultCargoFeatures(argv, features) { export function defaultEditorFeatures(target) { return target === 'win32' || target.includes('windows') - ? ['cocos-editor-execute'] + ? ['cocos-editor-execute', 'unity-editor-execute'] : []; } diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs index 80e31e9be..97592ea11 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs @@ -9,7 +9,7 @@ test('Windows release includes the same editor feature as development', () => { buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'), [ 'build', - '--features=cocos-editor-execute', + '--features=cocos-editor-execute,unity-editor-execute', '--target', 'x86_64-pc-windows-msvc', ], diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index fe8943608..c6985710a 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -143,7 +143,6 @@ const allowedUncalledTauriCommands = [ 'reload_agc_plugin', 'call_agc_plugin', 'read_agc_plugin_panel', - 'set_agc_plugin_project_path', 'set_agc_plugin_enabled', ]; const sourceExtensions = new Set([ diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 3ab0f2ced..e619c7123 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -1,7 +1,10 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { withDefaultCargoFeatures } from './cargo-features.mjs'; +import { + defaultEditorFeatures, + withDefaultCargoFeatures, +} from './cargo-features.mjs'; import { readAgcDevEndpoint, resolveAgcDevEndpoint, @@ -47,9 +50,8 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { ]; } -// `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature 下 -// 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具, -// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 +// 开发和发行构建使用同一平台编辑器 feature 集合。 +// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 function readDevCargoFeatures(env = process.env) { const override = env.AGC_DEV_CARGO_FEATURES; if (override !== undefined) { @@ -58,7 +60,7 @@ function readDevCargoFeatures(env = process.env) { .map((value) => value.trim()) .filter(Boolean); } - return process.platform === 'win32' ? ['cocos-editor-execute'] : []; + return defaultEditorFeatures(process.platform); } function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index bc1227832..bf8c04836 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1793,6 +1793,7 @@ dependencies = [ "ttf-parser", "typed_floats", "unicode-normalization", + "unity-editor-bridge", "url", "uuid", "windows-sys 0.61.2", @@ -6407,6 +6408,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unity-editor-bridge" +version = "0.1.0" +dependencies = [ + "editor-adapter-api", + "serde", + "serde_json", + "windows-sys 0.61.2", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 5c3e8eb64..db0fbd721 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -12,6 +12,7 @@ template-library-fixtures = [] cocos-editor = ["cocos-editor-bridge/process-discovery"] cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"] cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"] +unity-editor-execute = [] [build-dependencies] serde = { version = "1", features = ["derive"] } @@ -27,6 +28,7 @@ nalgebra = { version = "0.35.0", features = ["serde-serialize"] } agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false } editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" } +unity-editor-bridge = { path = "../../../plugins/agc-unity-editor/native/unity-editor-bridge" } base64 = "0.22" axum = "0.8" chromiumoxide = "0.9.1" diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 6bca6d67a..c39386b4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -196,6 +196,7 @@ fn main() { ); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); stage_bundled_codex_cli(&manifest_dir); + prepare_unity_editor_helper(&manifest_dir); stage_plugin_workspace(&manifest_dir); stage_cocos_editor_payload(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) @@ -266,6 +267,107 @@ fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) { #[cfg(not(windows))] fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} +/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。 +fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) { + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE"); + let target = env::var("TARGET").expect("Cargo TARGET"); + if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none() + || target != "x86_64-pc-windows-msvc" + { + return; + } + let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet"); + let mut sources = Vec::new(); + collect_unity_helper_sources(&root, &mut sources); + sources.sort(); + let mut fingerprint = Sha256::new(); + for source in &sources { + println!("cargo:rerun-if-changed={}", source.display()); + fingerprint.update( + source + .strip_prefix(&root) + .expect("helper source") + .to_string_lossy() + .as_bytes(), + ); + fingerprint.update([0]); + fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败")); + } + let fingerprint = format!("{:x}", fingerprint.finalize()); + let publish = root.join("publish/win-x64"); + let executable = publish.join("Agc.Unity.Attach.exe"); + let stamp = publish.join(".agc-source.sha256"); + println!("cargo:rerun-if-changed={}", executable.display()); + if unity_helper_publish_complete(&publish) + && fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint) + { + return; + } + assert!( + cfg!(windows), + "构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链" + ); + let status = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + ]) + .arg(root.join("build.ps1")) + .current_dir(&root) + .status() + .expect("无法启动 Unity helper 构建脚本"); + assert!( + status.success() && unity_helper_publish_complete(&publish), + "Unity helper 构建失败或缺少运行文件/许可" + ); + fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败"); +} + +fn unity_helper_publish_complete(publish: &std::path::Path) -> bool { + [ + "Agc.Unity.Attach.exe", + "NOTICE", + "THIRD-PARTY-NOTICES.txt", + "licenses/DotCraft-Apache-2.0.txt", + "licenses/Roslyn-MIT.txt", + "licenses/upstream.json", + "licenses/dotnet-LICENSE.TXT", + "licenses/dotnet-THIRD-PARTY-NOTICES.TXT", + "licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", + "licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf", + ] + .iter() + .all(|name| { + fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| { + metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0 + }) + }) +} + +fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec) { + for entry in fs::read_dir(root) + .expect("Unity helper 源码目录缺失") + .flatten() + { + let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败"); + assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接"); + let name = entry.file_name(); + if kind.is_dir() { + if !matches!( + name.to_str(), + Some("bin" | "obj" | "publish" | "native-build") + ) { + collect_unity_helper_sources(&entry.path(), sources); + } + } else if kind.is_file() { + sources.push(entry.path()); + } + } +} + /// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。 /// /// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 @@ -313,9 +415,15 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { for relative in [ std::path::PathBuf::from("src"), std::path::PathBuf::from("panels"), + std::path::PathBuf::from("skills"), std::path::PathBuf::from("native/payload"), + std::path::PathBuf::from("dotnet/publish/win-x64"), ] { - if relative == std::path::Path::new("native/payload") && !target.contains("windows") { + if (relative == std::path::Path::new("native/payload") && !target.contains("windows")) + || (relative == std::path::Path::new("dotnet/publish/win-x64") + && (target != "x86_64-pc-windows-msvc" + || env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none())) + { continue; } copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 1c27b262f..9e74a4c5d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -16,6 +16,7 @@ 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 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布居中责任唯一:使用 Phaser Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 的直接父容器用普通 block 按需要的宽高确定尺寸,不得在同一个 canvas 父容器上叠加 grid/flex 的 place-items、justify-content、align-items 居中或 margin:auto、translate 居中;若选择用 CSS 居中,则必须把 Phaser autoCenter 设为 NO_CENTER。外围布局仍可用 flex/grid,但同一个 canvas 的定位责任只能有一处。预览偏移先查项目自身的 CSS 与 Phaser 配置,不得用修改 AGC iframe 偏移来掩盖。改完布局后必须在桌面与移动视口以及 resize 后实测 canvas 相对游戏父容器的中心误差不超过 1 CSS px、无溢出,并按项目 scripts 构建 dist 后复验。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 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'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 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-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_UNITY_BUILTIN_PLUGIN_GUIDANCE: &str = "Unity 编辑器能力来自客户端内置插件 agc-unity-editor,工具为 agc_unity_execute(Runtime 为 unity.editor.execute)。当前工程是 Unity 时使用该工具执行 C#,先读取实际场景与对象再修改;不安装 UPM 或项目内 MCP,不改写为 Phaser。只支持 Windows x64 Mono Editor;缺少工具时报告客户端内置插件不可用。仅提交 code;主线程同步代码无法硬中止。needs-reconciliation 表示结果待人工核对,禁止自动重发、重启插件或切换项目以绕过阻断。只有真实 completed 回执才可报告成功。"; 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 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 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_ENGINE_FREEDOM_GUIDANCE: &str = "三维请求合同:用户要做三维(3D)游戏时,不受“新 Web 游戏固定 Phaser 4.2.1”的约束,由你自行选择三维技术栈(例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎),可以按需新增 npm 依赖,并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;做不到就用回复说明限制与原因。用户明确指定 Cocos、Unity、Godot 等编辑器而当前目录不具备对应工程结构时,仍按既有规则先说明不匹配再动作。"; @@ -271,7 +272,11 @@ pub(crate) fn direct_project_engine(root: &Path) -> DirectProjectEngine { { return DirectProjectEngine::Godot; } - if root.join("ProjectSettings/ProjectVersion.txt").is_file() { + if crate::project::discover_local_unity_project_root(root) + .ok() + .flatten() + .is_some() + { return DirectProjectEngine::Unity; } if direct_project_has_unreal_project_file(root) { @@ -4560,6 +4565,7 @@ fn build_direct_codex_system_prompt_with_search( DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), DIRECT_ENGINE_FREEDOM_GUIDANCE.to_string(), DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE.to_string(), + DIRECT_UNITY_BUILTIN_PLUGIN_GUIDANCE.to_string(), DIRECT_COCOS_CAPABILITY_GUIDE.to_string(), "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。工具返回 isError、构建失败、验证失败或试玩异常时,把错误当作调试上下文,读取当前项目、修复真实文件并重跑失败步骤,不要直接结束或伪造成功;鉴权、权限、余额、身份、历史、传输断开和操作状态不确定等安全错误才停止。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), format!("提示词与技能:{skill_index}"), @@ -6101,6 +6107,8 @@ mod tests { .expect("godot project"); } "unity" => { + std::fs::create_dir_all(root.path().join("Assets")).expect("unity assets"); + std::fs::create_dir_all(root.path().join("Packages")).expect("unity packages"); std::fs::create_dir_all(root.path().join("ProjectSettings")) .expect("unity settings"); std::fs::write( 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 c74690439..7431d1fd7 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 @@ -2592,6 +2592,51 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str) } } +#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] +async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { + let prepared = (|| { + if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&state.root) { + return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + } + enforce_project_permission_policy(&state.root, "unity.editor.execute")?; + bridge_reject_unknown_fields(arguments, &["code"])?; + let code = arguments + .get("code") + .and_then(Value::as_str) + .ok_or_else(|| "code 必须是 C# 代码".to_string())?; + if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') { + return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string()); + } + Ok(code.to_string()) + })(); + let code = match prepared { + Ok(code) => code, + Err(error) => { + return bridge_tool_result( + redact_agent_runtime_error(&state.root, &error, 480), + Vec::new(), + true, + ) + } + }; + let root = state.root.clone(); + let result = tokio::task::spawn_blocking(move || { + if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&root) { + return Err("当前 Unity 插件不可用".to_string()); + } + crate::editor_adapters::execute_unity_editor_code(&root, &code) + }) + .await; + match result { + Ok(Ok(response)) => { + let failed = response["ok"] != true || response["status"] != "completed"; + bridge_tool_result(redact_agent_runtime_error(&state.root, &response.to_string(), 32_000), Vec::new(), failed) + } + Ok(Err(error)) => bridge_tool_result(redact_agent_runtime_error(&state.root, &error, 480), Vec::new(), true), + Err(_) => bridge_tool_result(json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"error":"Unity 执行任务异常,请人工核对结果"}).to_string(), Vec::new(), true), + } +} + #[cfg(all(windows, feature = "cocos-editor-execute"))] async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { bridge_cocos_call(state, arguments, None).await @@ -2603,6 +2648,9 @@ async fn bridge_cocos_call( arguments: &Value, operation: Option<&str>, ) -> Value { + if !crate::builtin_plugins::is_enabled(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) { + return bridge_tool_result("Cocos 编辑器插件已禁用".to_string(), Vec::new(), true); + } if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&state.root) { return bridge_tool_result( "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用,agc_cocos_execute 不可用" @@ -2789,6 +2837,8 @@ 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, target_arch = "x86_64", feature = "unity-editor-execute"))] + "agc_unity_execute" => bridge_unity_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 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 47820340a..5fb04936b 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 @@ -75,7 +75,14 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option async fn direct_tools_mcp_specs() -> Value { let mut cocos_editor_available = false; - if cfg!(all(windows, feature = "cocos-editor-execute")) { + let mut unity_editor_available = false; + if cfg!(all(windows, feature = "cocos-editor-execute")) + || cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )) + { // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。 if let Ok(result) = tokio::time::timeout( std::time::Duration::from_secs(5), @@ -96,10 +103,22 @@ async fn direct_tools_mcp_specs() -> Value { .iter() .any(|tool| tool == crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME) }); + unity_editor_available = availability + .as_ref() + .and_then(|v| v["tools"].as_array()) + .is_some_and(|tools| { + tools + .iter() + .any(|tool| tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME) + }); } } } - direct_tools_mcp_specs_for(controlled_web_search_enabled(), cocos_editor_available) + direct_tools_mcp_specs_for_plugins( + controlled_web_search_enabled(), + cocos_editor_available, + unity_editor_available, + ) } /// 工具 kind(wire 值)对应的提示词上限。 @@ -128,7 +147,16 @@ fn resource_tool_prompt_schema_max_chars() -> usize { .unwrap_or(0) } -fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value { +#[cfg(test)] +fn direct_tools_mcp_specs_for(controlled_web_search: bool, cocos_editor_available: bool) -> Value { + direct_tools_mcp_specs_for_plugins(controlled_web_search, cocos_editor_available, false) +} + +fn direct_tools_mcp_specs_for_plugins( + controlled_web_search: bool, + _cocos_editor_available: bool, + _unity_editor_available: bool, +) -> Value { let tools = vec![ json!({ "name": "client.session.info", @@ -568,6 +596,14 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab .cloned(), ); } + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + if _unity_editor_available { + tools.push(json!({ + "name": "agc_unity_execute", + "description": "在当前项目已打开的 Windows x64 Unity Mono Editor 执行 C#,可使用 return 返回值。仅提交 code;宿主绑定项目及进程。needs-reconciliation 或超时后禁止自动重发。", + "inputSchema": {"type":"object", "properties":{"code":{"type":"string", "minLength":1, "maxLength":131072}}, "required":["code"], "additionalProperties":false} + })); + } if controlled_web_search { tools.push(json!({ "name": "agc_web_search", @@ -655,6 +691,24 @@ async fn call_agc_cocos_execute(arguments: &Value) -> Value { call_client_tool_bridge("agc_cocos_execute", arguments).await } +#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] +async fn call_agc_unity_execute(arguments: &Value) -> Value { + let validated = validate_tool_object_fields(arguments, &["code"]).and_then(|()| { + let code = arguments + .get("code") + .and_then(Value::as_str) + .ok_or_else(|| "code 必须是 C# 代码".to_string())?; + if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') { + return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string()); + } + Ok(()) + }); + if let Err(error) = validated { + return mcp_tool_result(error, Vec::new(), true); + } + call_client_tool_bridge("agc_unity_execute", arguments).await +} + fn mcp_success(id: Value, result: Value) -> Value { json!({ "jsonrpc": "2.0", "id": id, "result": result }) } @@ -1757,6 +1811,8 @@ 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, target_arch = "x86_64", feature = "unity-editor-execute"))] + "agc_unity_execute" => call_agc_unity_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 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 78a9cf3d6..e73786ad3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -1620,7 +1620,7 @@ pub(crate) fn agent_runtime_tool_action_input_summary( .unwrap_or(160) ), "command.run_limited" => format!("commandId={}", text(&["commandId", "command_id", "id"])), - "cocos.editor.execute" => format!( + "cocos.editor.execute" | "unity.editor.execute" => format!( "codeChars={} · codeSha256={:x}", chars(&["code"]), Sha256::digest( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 96d7f9b63..a685a2d3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -383,6 +383,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ true, || observe_agent_runtime_cocos_editor_execute(root, action, pending_action), ), + "unity.editor.execute" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + true, + || observe_agent_runtime_unity_editor_execute(root, action, pending_action), + ), "preview.validate" => { observe_agent_runtime_preview_validate( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index e1fe9f4a9..40924de58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -97,6 +97,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( "command.run_limited" => Some("command.run_limited"), #[cfg(feature = "cocos-editor-execute")] "cocos.editor.execute" => Some("cocos.editor.execute"), + "unity.editor.execute" => Some("unity.editor.execute"), "preview.start" => Some("preview.start"), "preview.validate" => Some("preview.validate"), "image.inspect" => Some("image.inspect"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index b38ed1023..f49a1dff7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -247,7 +247,10 @@ fn build_game_creator_agent_background_tool_plan_request_at( let relaxed_prompt = format!( "你正在执行一个自主游戏构建任务。请按自己的判断规划并直接调用当前广告的原生工具完成目标;任务可以与其它 Agent 并行,依赖只作为参考,不要等待或索要平台资产/验收回执。已有观察只代表已发生的事实,完成后直接调用 respond_to_user。\n\n运行上下文:\n{context}\n\n任务:\n{effective_task}\n\n已有观察:\n{observations_json}" ); - let mut function_tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?; + let mut function_tools = + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project( + root, agent_id, + )?; remove_relaxed_autonomous_platform_validation_tools(&mut function_tools)?; // Platform-backed generation remains an optional capability. A // relaxed run may proceed with all ordinary project tools when no @@ -479,9 +482,11 @@ fn build_game_creator_agent_background_tool_plan_request_at( .with_api_kind(api_kind) .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) - .with_function_tools(build_agent_runtime_native_function_tools_for_agent( - agent_id, - )?) + .with_function_tools( + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project( + root, agent_id, + )?, + ) .with_tool_choice(platform_llm::LlmToolChoice::Required); if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools(&mut request.function_tools)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 09da3466e..697738daa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -969,7 +969,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at || force_autonomous_pre_mutation { request.function_tools = - build_agent_runtime_native_function_tools_for_agent(agent_id)?; + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(root, agent_id)?; if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools( &mut request.function_tools, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 68e315d3d..0a353cef5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -70,6 +70,9 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { if crate::builtin_plugins::cocos_editor_agent_tool_available() { tools.push(crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME); } + if crate::builtin_plugins::unity_editor_agent_tool_available() { + tools.push(crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME); + } tools } @@ -154,6 +157,11 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( let mut confirm_tools = Vec::new(); let mut denied_tools = Vec::new(); for tool in agent_runtime_executable_tools() { + if tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME + && !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) + { + continue; + } if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) { denied_tools.push(tool.to_string()); continue; @@ -196,6 +204,10 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( run_profile_binding_fingerprint: String::new(), allowed_tools: agent_runtime_executable_tools() .into_iter() + .filter(|tool| { + *tool != crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME + || crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) + }) .map(str::to_string) .collect(), auto_tools, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 41235d21e..177f6d039 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -11,6 +11,7 @@ mod goal_contract; mod helpers; mod isolated_joins; mod media; +mod unity_editor; pub(in crate::agent) use media::design_foundation_ui_page_output_path_is_valid; mod policy; mod preview; @@ -38,6 +39,7 @@ pub(in crate::agent) use project_ops::*; pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; pub(in crate::agent) use ui_workflow::*; +pub(in crate::agent) use unity_editor::*; #[cfg(test)] pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs new file mode 100644 index 000000000..d0cd9fe55 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs @@ -0,0 +1,79 @@ +use super::*; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct UnityEditorExecuteInput { + code: String, +} + +pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute( + root: &Path, + action: &AgentRuntimeToolAction, + pending_action: Option<&AgentRuntimePendingToolAction>, +) -> AgentRuntimeToolObservation { + let execution = (|| { + let input: UnityEditorExecuteInput = serde_json::from_value(action.input.clone()) + .map_err(|error| format!("unity.editor.execute 输入无效:{error}"))?; + if pending_action.is_none() { + return Err("unity.editor.execute 必须绑定 durable pending action".to_string()); + } + if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) { + return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + } + crate::editor_adapters::execute_unity_editor_code(root, &input.code) + })(); + match execution { + Ok(response) => { + let status = match response["status"].as_str() { + Some("completed") if response["ok"] == true => "ok", + Some("needs-reconciliation") => { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } + _ => "failed", + }; + AgentRuntimeToolObservation { + tool: "unity.editor.execute".to_string(), + status: status.to_string(), + summary: match status { + "ok" => "Unity 编辑器已返回执行成功回执", + "needs-reconciliation" => "Unity 执行结果待人工核对,禁止自动重发", + _ => "Unity 编辑器执行失败", + } + .to_string(), + detail: Some(redact_agent_runtime_project_paths( + root, + &response.to_string(), + 32_000, + )), + } + } + Err(error) => AgentRuntimeToolObservation { + tool: "unity.editor.execute".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 480), + detail: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unity_execute_requires_pending_action_and_rejects_project_override() { + for input in [ + serde_json::json!({"code":"return 2;"}), + serde_json::json!({"code":"return 2;", "projectPath":"C:/other"}), + ] { + let action = AgentRuntimeToolAction { + tool: "unity.editor.execute".to_string(), + reason: None, + input, + }; + let observation = + observe_agent_runtime_unity_editor_execute(Path::new("C:/unity"), &action, None); + assert_eq!(observation.status, "failed"); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 2240b3341..952d598d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -253,18 +253,13 @@ fn build_agent_runtime_native_capability_registry( fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry, String> { - // 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即 - // 生效,又不需要每次调用都重建 registry。 - static ENABLED_REGISTRY: OnceLock, String>> = OnceLock::new(); - static DISABLED_REGISTRY: OnceLock, String>> = - OnceLock::new(); - // 缓存选择与构建消费同一份快照,避免开关变化污染另一份永久缓存。 + // 两个独立开关产生四份目录,使用同一快照选缓存并构建。 + static REGISTRIES: [OnceLock, String>>; 4] = + [const { OnceLock::new() }; 4]; let tools = agent_runtime_native_executable_tools(); - let cache = if tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME) { - &ENABLED_REGISTRY - } else { - &DISABLED_REGISTRY - }; + let index = usize::from(tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME)) + | (usize::from(tools.contains(&crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME)) << 1); + let cache = ®ISTRIES[index]; cache .get_or_init(|| build_agent_runtime_native_capability_registry(tools)) .as_ref() @@ -311,6 +306,18 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( Ok(functions) } +pub(crate) fn build_agent_runtime_native_function_tools_for_project( + root: &std::path::Path, + agent_id: &str, +) -> Result, String> { + let mut tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?; + if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) { + let name = native_runtime_function_name_for_tool("unity.editor.execute"); + tools.retain(|tool| tool.name != name); + } + Ok(tools) +} + pub(crate) fn agent_runtime_native_tool_allowed_for_agent(tool: &str) -> bool { agent_runtime_native_capability_registry() .ok() @@ -1044,6 +1051,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "cocos.editor.execute" => { "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。" } + "unity.editor.execute" => "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。", "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", "agent.delegate" => { @@ -1241,7 +1249,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } }), "command.exec" | "command.start" => command_start_input_schema(), - "cocos.editor.execute" => json!({ + "cocos.editor.execute" | "unity.editor.execute" => json!({ "type": "object", "required": ["code"], "additionalProperties": false, 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 0896b32eb..a3a002a60 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 @@ -17,6 +17,8 @@ pub(crate) const AGC_COCOS_EDITOR_PLUGIN_ID: &str = "agc-cocos-editor"; /// 该插件在 Agent 侧对应的 Runtime 工具名。 pub(crate) const AGC_COCOS_EDITOR_TOOL_NAME: &str = "cocos.editor.execute"; +pub(crate) const AGC_UNITY_EDITOR_PLUGIN_ID: &str = "agc-unity-editor"; +pub(crate) const AGC_UNITY_EDITOR_TOOL_NAME: &str = "unity.editor.execute"; const STATE_FILE_NAME: &str = "builtin-plugins.json"; const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1"; @@ -24,31 +26,34 @@ const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum BuiltinPlugin { CocosEditor, + UnityEditor, } impl BuiltinPlugin { pub(crate) fn id(self) -> &'static str { match self { Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID, + Self::UnityEditor => AGC_UNITY_EDITOR_PLUGIN_ID, } } /// 未持久化任何开关时的默认状态。 fn default_enabled(self) -> bool { match self { - Self::CocosEditor => true, + Self::CocosEditor | Self::UnityEditor => true, } } /// 该插件是否向 Agent 暴露 Runtime 工具。 fn exposes_agent_tools(self) -> bool { match self { - Self::CocosEditor => true, + Self::CocosEditor | Self::UnityEditor => true, } } } -pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[BuiltinPlugin::CocosEditor]; +pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = + &[BuiltinPlugin::CocosEditor, BuiltinPlugin::UnityEditor]; pub(crate) fn builtin_plugin(id: &str) -> Option { BUILTIN_PLUGINS @@ -237,7 +242,16 @@ fn persist(guard: &BuiltinPluginState) -> Result<(), String> { /// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。 pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool { plugin.exposes_agent_tools() - && cfg!(all(windows, feature = "cocos-editor-execute")) + && match plugin { + BuiltinPlugin::CocosEditor => cfg!(all(windows, feature = "cocos-editor-execute")), + BuiltinPlugin::UnityEditor => { + cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )) && unity_editor_bridge::is_supported_platform() + } + } && is_enabled(plugin.id()) } @@ -257,6 +271,7 @@ pub(crate) fn cocos_editor_agent_tool_available_for_project(root: &Path) -> bool } pub(crate) fn available_agent_tools() -> Vec<&'static str> { + let mut available = Vec::new(); if cocos_editor_agent_tool_available() { let mut tools = vec![AGC_COCOS_EDITOR_TOOL_NAME]; tools.extend( @@ -264,19 +279,39 @@ pub(crate) fn available_agent_tools() -> Vec<&'static str> { .iter() .filter_map(|tool| tool["name"].as_str()), ); - tools - } else { - Vec::new() + available.extend(tools); } + if unity_editor_agent_tool_available() { + available.push(AGC_UNITY_EDITOR_TOOL_NAME); + } + available } /// Project-scoped variant used by the isolated DirectProject MCP bridge. /// Without a project root the safe result is an empty Cocos tool set. pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str> { - if !cocos_editor_agent_tool_available_for_project(root) { - return Vec::new(); - } available_agent_tools() + .into_iter() + .filter(|tool| { + if *tool == AGC_UNITY_EDITOR_TOOL_NAME { + unity_editor_agent_tool_available_for_project(root) + } else { + cocos_editor_agent_tool_available_for_project(root) + } + }) + .collect() +} + +pub(crate) fn unity_editor_agent_tool_available() -> bool { + agent_tool_available(BuiltinPlugin::UnityEditor) +} + +pub(crate) fn unity_editor_agent_tool_available_for_project(root: &Path) -> bool { + unity_editor_agent_tool_available() + && crate::project::discover_local_unity_project_root(root) + .ok() + .flatten() + .is_some() } #[cfg(test)] @@ -294,6 +329,47 @@ mod tests { .unwrap_or_else(|error| error.into_inner()) } + #[test] + fn unity_tool_visibility_requires_project_platform_and_independent_toggle() { + let _guard = test_lock(); + let config = tempdir().unwrap(); + initialize(config.path()).unwrap(); + let project = tempdir().unwrap(); + for directory in ["Assets", "Packages", "ProjectSettings"] { + fs::create_dir(project.path().join(directory)).unwrap(); + } + fs::write( + project.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.1f1", + ) + .unwrap(); + let supported = cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )); + assert_eq!( + available_agent_tools_for_project(project.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME), + supported + ); + assert!( + !available_agent_tools_for_project(config.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME) + ); + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap(); + assert_eq!( + unity_editor_agent_tool_available_for_project(project.path()), + supported + ); + set_enabled(AGC_UNITY_EDITOR_PLUGIN_ID, false).unwrap(); + assert!(!unity_editor_agent_tool_available_for_project( + project.path() + )); + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap(); + assert!(!unity_editor_agent_tool_available_for_project( + project.path() + )); + } + #[test] fn builtin_plugins_default_to_enabled_and_reject_unknown_ids() { let _guard = test_lock(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 79d0dc2a4..555170718 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -676,6 +676,21 @@ pub(crate) fn import_local_cocos_project( import_local_cocos_project_at(root, project_id.trim(), name.trim()) } +#[tauri::command] +pub(crate) fn import_local_unity_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + if discover_local_unity_project_root(root)?.is_none() { + return Err("所选目录不是有效的 Unity 项目".to_string()); + } + let _lock = acquire_project_write_lock(root, "project.create")?; + import_local_unity_project_at(root, project_id.trim(), name.trim()) +} + #[tauri::command] pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -737,6 +752,7 @@ pub(crate) fn inspect_local_project_directory_sync( let recent_run_trace = recent_game_creator_run_trace(root); let godot_project_root = discover_local_godot_project_root(root)?; let cocos_project_root = discover_local_cocos_project_root(root)?; + let unity_project_root = discover_local_unity_project_root(root)?; Ok(LocalProjectDirectoryStatus { project_path: root.to_string_lossy().into_owned(), exists: root.exists(), @@ -746,6 +762,8 @@ pub(crate) fn inspect_local_project_directory_sync( godot_project_root, is_cocos_project: cocos_project_root.is_some(), cocos_project_root, + is_unity_project: unity_project_root.is_some(), + unity_project_root, project_name: game_creator_project_name(root), modified_at: project_directory_modified_at(root), manifest_error: game_creator_project_manifest_error(root), diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index 4c5cf1444..b77ec5a7a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -11,12 +11,339 @@ use std::path::PathBuf; use tauri::Manager; use crate::plugin_host::PluginHost; +use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; +use serde_json::{json, Value}; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; + +struct UnityPendingDelivery { + id: String, + outcome_known: bool, +} + +fn unity_pending_delivery() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(None)) +} + +pub(crate) fn unity_execution_fence_path(config_dir: &Path) -> std::path::PathBuf { + config_dir.join("unity-editor-execution.pending") +} + +pub(crate) fn unity_uncertain_fence_path(config_dir: &Path) -> std::path::PathBuf { + config_dir.join("unity-editor-execution.uncertain") +} + +pub(crate) fn mark_unity_execution_uncertain_at(config_dir: &Path) -> Result<(), String> { + use std::io::Write; + let path = unity_uncertain_fence_path(config_dir); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + { + Ok(mut file) => file + .write_all(b"needs-reconciliation") + .and_then(|_| file.sync_all()) + .map_err(|_| "无法持久记录 Unity 执行不确定状态".to_string()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(_) => Err("无法持久记录 Unity 执行不确定状态".to_string()), + } +} + +pub(crate) fn mark_unity_execution_uncertain() -> Result<(), String> { + let config = crate::game_creator_runtime_config_dir_lock() + .lock() + .map_err(|_| "Unity 配置锁损坏")? + .clone() + .ok_or("Unity 执行宿主尚未初始化")?; + mark_unity_execution_uncertain_at(&config) +} + +fn current_unity_execution_fence() -> Result { + crate::game_creator_runtime_config_dir_lock() + .lock() + .map_err(|_| "Unity 配置锁损坏")? + .as_deref() + .map(unity_execution_fence_path) + .ok_or_else(|| "Unity 执行宿主尚未初始化".to_string()) +} + +fn remove_unity_execution_fence(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("无法清理 Unity 执行确认记录,继续阻断执行".to_string()), + } +} + +/// 调用方必须同时独占 GUI 参与锁及 Runner 实例锁,保证这是全部宿主退出后的首次打开。 +pub(crate) fn reset_unity_execution_fence_for_fresh_gui(config_dir: &Path) -> Result<(), String> { + remove_unity_execution_fence(&unity_execution_fence_path(config_dir))?; + remove_unity_execution_fence(&unity_uncertain_fence_path(config_dir)) +} + +pub(crate) fn unity_execute_receipt_is_valid(value: &Value) -> bool { + if value["retryAllowed"] != false { + return false; + } + let valid_error = value["error"]["code"] + .as_str() + .is_some_and(|code| !code.trim().is_empty()) + && value["error"]["message"] + .as_str() + .is_some_and(|message| !message.trim().is_empty()); + match value["status"].as_str() { + Some("completed") => { + value["ok"] == true && value["dispatched"] == true && value.get("result").is_some() + } + Some("failed") => value["ok"] == false && value["dispatched"].is_boolean() && valid_error, + Some("needs-reconciliation") => { + value["ok"] == false && value["dispatched"] == true && valid_error + } + _ => false, + } +} + +fn unity_reconciliation(message: &str) -> Value { + json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"needs-reconciliation","message":message}}) +} + +pub(crate) fn unity_not_dispatched(message: &str) -> Value { + json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false,"error":{"code":"not-dispatched","message":message}}) +} + +/// 仅在长寿命 Runner 中触达 native service,GUI / Runtime / DirectProject 共用此入口。 +pub(crate) fn unity_editor_rpc(method: &str, params: Value) -> Result { + let method = method.strip_prefix("editor.").unwrap_or(method); + if crate::runner::external_agent_runner_is_server_process() { + unity_editor_rpc_owned(method, params, None) + } else { + crate::runner::call_external_unity_editor(method, params) + } +} + +pub(crate) fn execute_unity_editor_code(root: &Path, code: &str) -> Result { + unity_editor_rpc( + "execute", + json!({"projectPath":root.to_string_lossy(),"code":code,"timeoutMs":60000}), + ) +} + +/// GUI 读不到执行回执时不会发送 ack;该门闩不能被插件、连接或项目生命周期清除。 +pub(crate) fn unity_editor_rpc_owned( + method: &str, + params: Value, + delivery_id: Option<&str>, +) -> Result { + let method = method.strip_prefix("editor.").unwrap_or(method); + if !matches!( + method, + "detect" | "connect" | "status" | "execute" | "disconnect" + ) { + return Err("Unity RPC 方法不受支持".to_string()); + } + if method == "disconnect" { + unity_editor_bridge::disconnect_unity_editor(); + return Ok( + json!({"adapter":"unity-editor","connected":false,"pid":null,"projectPath":params.get("projectPath"),"version":null}), + ); + } + if method == "connect" { + unity_editor_bridge::disconnect_unity_editor(); + } + let project = params + .get("projectPath") + .and_then(Value::as_str) + .ok_or("缺少 projectPath")?; + if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(Path::new(project)) { + return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + } + let mut delivery = if method == "execute" { + let mut pending = match unity_pending_delivery().try_lock() { + Ok(pending) => pending, + Err(std::sync::TryLockError::WouldBlock) => { + return Ok(unity_not_dispatched( + "Unity 编辑器已有请求正在执行,请等待回执", + )) + } + Err(std::sync::TryLockError::Poisoned(_)) => { + return Ok(unity_reconciliation("Unity 执行状态异常,请人工核对")) + } + }; + let fence = current_unity_execution_fence()?; + let uncertain_fence = fence.with_extension("uncertain"); + if uncertain_fence.exists() { + return Ok(unity_reconciliation( + "Unity 执行回执未确认,请核对后退出全部宿主再重新打开", + )); + } + if pending + .as_ref() + .is_some_and(|pending| pending.outcome_known) + { + return Ok(unity_not_dispatched( + "Unity 上一条执行正在等待客户端确认回执", + )); + } + if pending.is_some() || fence.exists() { + return Ok(unity_reconciliation( + "先前 Unity 执行回执尚未确认,退出全部 AGC 和 Runner 后重新打开才可恢复", + )); + } + let id = delivery_id + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&fence) + .map_err(|_| "无法独占保存 Unity 执行确认记录,未发送请求")?; + file.write_all(id.as_bytes()) + .and_then(|_| file.sync_all()) + .map_err(|_| "无法持久保存 Unity 执行确认记录,未发送请求")?; + *pending = Some(UnityPendingDelivery { + id, + outcome_known: false, + }); + Some(pending) + } else { + None + }; + let result = unity_editor_bridge::UnityEditorAdapter::new(Vec::new()).rpc(method, params); + if method == "execute" { + // native 的 Err 均为发送前失败;发送后的未知状态由结构化 result 携带并锁存。 + let mut result = result.unwrap_or_else(|error| json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false,"error":{"code":"not-dispatched","message":error}})); + if !unity_execute_receipt_is_valid(&result) { + result = unity_reconciliation("Unity 原生执行回执格式损坏,禁止自动重发"); + } + let known = result["status"] != "needs-reconciliation"; + // 与本次 pending 写入同一临界区决定确认,避免返回后再次抢锁造成误判。 + if delivery_id.is_some() { + result["ackRequired"] = json!(known); + } + if let Some(pending) = delivery.as_mut() { + if let Some(pending) = pending.as_mut() { + pending.outcome_known = known; + } + if delivery_id.is_none() && known { + if current_unity_execution_fence() + .and_then(|path| remove_unity_execution_fence(&path)) + .is_err() + { + return Ok(unity_reconciliation( + "Unity 执行已返回,但确认记录无法提交,请人工核对", + )); + } + **pending = None; + } + } + return Ok(result); + } + result +} + +pub(crate) fn acknowledge_unity_editor_delivery(request_id: &str) -> Result<(), String> { + let mut pending = unity_pending_delivery() + .try_lock() + .map_err(|_| "Unity 执行尚未结束")?; + if !pending + .as_ref() + .is_some_and(|pending| pending.id == request_id && pending.outcome_known) + { + return Err("Unity 回执确认身份不匹配或执行结果仍不确定".to_string()); + } + remove_unity_execution_fence(¤t_unity_execution_fence()?)?; + *pending = None; + Ok(()) +} + +#[cfg(test)] +pub(crate) fn unity_delivery_requires_ack(request_id: &str) -> bool { + unity_pending_delivery() + .try_lock() + .ok() + .is_some_and(|pending| { + pending + .as_ref() + .is_some_and(|pending| pending.id == request_id && pending.outcome_known) + }) +} + +pub(crate) fn disconnect_unity_editor_connection() { + if crate::runner::external_agent_runner_is_server_process() { + unity_editor_bridge::disconnect_unity_editor(); + } else { + let _ = crate::runner::disconnect_external_unity_editor(); + } +} + +/// GUI 只代理已有 Runner RPC,不创建第二份 helper 或不确定门闩。 +struct RunnerUnityEditorAdapter; + +impl EditorAdapter for RunnerUnityEditorAdapter { + fn id(&self) -> &'static str { + "unity-editor" + } + fn detect(&self, project_path: &Path) -> Result { + serde_json::from_value(unity_editor_rpc( + "detect", + json!({"projectPath":project_path.to_string_lossy()}), + )?) + .map_err(|_| "Unity 探测回执格式无效".to_string()) + } + fn connect( + &mut self, + pid: u32, + project_path: &Path, + _version: &str, + ) -> Result { + serde_json::from_value(unity_editor_rpc( + "connect", + json!({"processId":pid,"projectPath":project_path.to_string_lossy()}), + )?) + .map_err(|_| "Unity 连接回执格式无效".to_string()) + } + fn disconnect(&mut self) { + disconnect_unity_editor_connection(); + } + fn translate_rpc(&self, method: &str, params: Value) -> Result { + unity_editor_bridge::UnityEditorAdapter::new(Vec::new()).translate_rpc(method, params) + } + fn rpc(&self, method: &str, params: Value) -> Result { + unity_editor_rpc(method, params) + } +} /// 随包 payload 相对资源根目录的位置,与 `tauri.windows.conf.json` 的资源映射保持一致。 #[allow(dead_code)] pub(crate) const COCOS_BRIDGE_PAYLOAD_RELATIVE: &str = "plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"; +#[allow(dead_code)] +pub(crate) const UNITY_ATTACH_HELPER_RELATIVE: &str = + "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe"; + +/// Runner 没有 GUI setup,仍只从随包位置或编译期工作区取受控 helper。 +pub(crate) fn configure_unity_helper_for_runtime() -> Result<(), String> { + let mut candidates = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join(UNITY_ATTACH_HELPER_RELATIVE)); + } + } + #[cfg(debug_assertions)] + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .ok_or("插件工作区目录不可用")? + .join(UNITY_ATTACH_HELPER_RELATIVE), + ); + unity_editor_bridge::configure_helper_candidates(candidates) +} + pub(crate) fn register_linked_editor_adapters( app: &tauri::AppHandle, host: &PluginHost, @@ -31,6 +358,10 @@ pub(crate) fn register_linked_editor_adapters( { let _ = (app, host); } + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + { + host.register_editor_adapter(Box::new(RunnerUnityEditorAdapter))?; + } Ok(()) } @@ -52,3 +383,91 @@ fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { } candidates } + +#[cfg(test)] +mod unity_receipt_tests { + use super::*; + + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + #[test] + fn unity_delivery_ack_distinguishes_busy_disabled_and_known_pre_dispatch_failure() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + for directory in ["Assets", "Packages", "ProjectSettings"] { + std::fs::create_dir(project.path().join(directory)).unwrap(); + } + std::fs::write( + project.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.1f1", + ) + .unwrap(); + let previous_config = crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() + .clone(); + *crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() = Some(config.path().to_path_buf()); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let params = json!({"projectPath":project.path().to_string_lossy(),"code":"return 2;"}); + { + let _busy = unity_pending_delivery().lock().unwrap(); + let result = + unity_editor_rpc_owned("execute", params.clone(), Some("busy-request")).unwrap(); + assert_eq!(result["status"], "failed"); + assert_eq!(result["dispatched"], false); + assert!(!unity_delivery_requires_ack("busy-request")); + } + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + false, + ) + .unwrap(); + assert!( + unity_editor_rpc_owned("execute", params.clone(), Some("disabled-request")).is_err() + ); + assert!(!unity_delivery_requires_ack("disabled-request")); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + true, + ) + .unwrap(); + // 空代码在 native 发送前失败,但本次 Runner delivery 已建 fence,仍须确认回执。 + let result = unity_editor_rpc_owned( + "execute", + json!({"projectPath":project.path().to_string_lossy(),"code":""}), + Some("known-failure"), + ) + .unwrap(); + assert_eq!(result["status"], "failed"); + assert_eq!(result["dispatched"], false); + assert_eq!(result["ackRequired"], true); + assert!(unity_delivery_requires_ack("known-failure")); + assert!(acknowledge_unity_editor_delivery("wrong-id").is_err()); + assert!(unity_execution_fence_path(config.path()).exists()); + acknowledge_unity_editor_delivery("known-failure").unwrap(); + assert!(!unity_execution_fence_path(config.path()).exists()); + *crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() = previous_config; + } + + #[test] + fn unity_ack_requires_complete_consistent_execution_receipt() { + assert!(unity_execute_receipt_is_valid( + &json!({"status":"completed","ok":true,"dispatched":true,"retryAllowed":false,"result":null}) + )); + assert!(unity_execute_receipt_is_valid( + &json!({"status":"failed","ok":false,"dispatched":false,"retryAllowed":false,"error":{"code":"missing-helper","message":"no helper"}}) + )); + for value in [ + json!({"status":"completed","ok":true}), + json!({"status":"completed","ok":false,"dispatched":true,"retryAllowed":false,"result":1}), + json!({"status":"failed","ok":false,"dispatched":true,"retryAllowed":false}), + json!({"status":"needs-reconciliation","ok":false,"dispatched":false,"retryAllowed":false,"error":"lost"}), + ] { + assert!(!unity_execute_receipt_is_valid(&value)); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs index d8df54e84..d523dc20a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs @@ -45,6 +45,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ "command.start", "command.stdin", "cocos.editor.execute", + "unity.editor.execute", "preview.start", "agent.delegate", "agent.spawn_isolated", @@ -66,6 +67,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[ "command.start", "command.stdin", "cocos.editor.execute", + "unity.editor.execute", "preview.start", "agent.delegate", "agent.spawn_isolated", diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 811456b96..47ba671d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -276,6 +276,8 @@ struct LocalProjectDirectoryStatus { godot_project_root: Option, is_cocos_project: bool, cocos_project_root: Option, + is_unity_project: bool, + unity_project_root: Option, project_name: Option, modified_at: Option, manifest_error: Option, @@ -2508,6 +2510,7 @@ fn main() { download_game_template, import_local_godot_project, import_local_cocos_project, + import_local_unity_project, is_local_project_directory_non_empty, inspect_local_project_directory, pick_local_project_directory, 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 389d0486d..71117b0df 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 @@ -767,22 +767,89 @@ fn permission_for_method(method: &str) -> Option<&'static str> { } } -fn has_cocos_editor_adapter(editors: &EditorRegistry) -> Result { +fn has_editor_adapter(editors: &EditorRegistry, adapter: &str) -> Result { Ok(editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())? - .contains_key("cocos-editor")) + .contains_key(adapter)) } fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), String> { - if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !has_cocos_editor_adapter(editors)? - { - return Err("当前客户端不支持 Cocos 编辑器桥接".to_string()); + let adapter = match id { + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID => "cocos-editor", + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => "unity-editor", + _ => return Ok(()), + }; + if !has_editor_adapter(editors, adapter)? { + let name = if adapter == "cocos-editor" { + "Cocos" + } else { + "Unity" + }; + return Err(format!("当前客户端不支持 {name} 编辑器桥接")); } Ok(()) } +fn plugin_matches_project(id: &str, project: Option<&Path>) -> bool { + match id { + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID => project.is_some_and(|path| { + crate::project::discover_local_cocos_project_root(path) + .ok() + .flatten() + .is_some() + }), + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => project.is_some_and(|path| { + crate::project::discover_local_unity_project_root(path) + .ok() + .flatten() + .is_some() + }), + _ => true, + } +} + +fn require_plugin_project(id: &str, project: &ProjectContext) -> Result<(), String> { + if !plugin_matches_project( + id, + project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())? + .as_deref(), + ) { + return Err("编辑器插件与当前项目类型不匹配".to_string()); + } + Ok(()) +} + +fn controlled_editor_params(project: &Path, mut params: Value) -> Result { + if params.is_null() { + params = json!({}); + } + let params = params + .as_object_mut() + .ok_or_else(|| "编辑器参数必须是对象".to_string())?; + let canonical = project + .canonicalize() + .map_err(|_| "当前项目目录不可读".to_string())?; + if let Some(explicit) = params.get("projectPath") { + let explicit = explicit + .as_str() + .ok_or_else(|| "projectPath 必须是字符串".to_string())?; + let explicit = Path::new(explicit) + .canonicalize() + .map_err(|_| "编辑器项目目录不可读".to_string())?; + if explicit != canonical { + return Err("编辑器 projectPath 必须匹配当前受控项目".to_string()); + } + } + params.insert( + "projectPath".to_string(), + json!(canonical.to_string_lossy()), + ); + Ok(Value::Object(params.clone())) +} + impl PluginHost { pub(crate) fn initialize(&self, config_dir: &Path) -> Result<(), String> { let root = plugin_root(config_dir)?; @@ -953,23 +1020,17 @@ impl PluginHost { .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; self.scan_locked(&mut state, &root)?; - let cocos_project = state + let project = state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }); - let cocos_available = cocos_project && has_cocos_editor_adapter(&state.editors)?; + .clone(); state .plugins .values() .filter(|record| { - record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_available + plugin_matches_project(&record.id, project.as_deref()) + && require_plugin_adapter(&record.id, &state.editors).is_ok() }) .map(|record| self.summary_locked(record)) .collect() @@ -1035,20 +1096,7 @@ impl PluginHost { .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let active_project = state.active_project.clone(); require_plugin_adapter(id, &state.editors)?; - if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }) - { - return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); - } + require_plugin_project(id, &active_project)?; let editors = state.editors.clone(); let record = state .plugins @@ -1128,6 +1176,9 @@ impl PluginHost { } if !enabled { let _ = self.stop(id); + if id == crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID { + crate::editor_adapters::disconnect_unity_editor_connection(); + } } crate::builtin_plugins::set_enabled(id, enabled)?; self.refresh() @@ -1147,21 +1198,7 @@ impl PluginHost { .plugins .get(id) .ok_or_else(|| "插件不存在".to_string())?; - if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !state - .active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }) - { - return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); - } + require_plugin_project(id, &state.active_project)?; if record.running.is_none() || !record.manifest.permissions.contains("ui.register") { return Err("插件面板未激活".to_string()); } @@ -1207,21 +1244,7 @@ impl PluginHost { .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; - if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !state - .active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }) - { - return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); - } + require_plugin_project(id, &state.active_project)?; let record = state .plugins .get_mut(id) @@ -1260,6 +1283,8 @@ impl PluginHost { ) }; let deadline = Instant::now() + response_timeout; + let unity_execute = id == crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID + && method == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME; let (write_sender, write_receiver) = mpsc::channel(); thread::spawn(move || { let _ = write_sender.send(write_rpc_shared( @@ -1267,14 +1292,18 @@ impl PluginHost { &json!({"jsonrpc":"2.0", "id":request_id, "method":method, "params":params}), )); }); + let mut command_sent = false; let result = match write_receiver.recv_timeout(RPC_TIMEOUT) { - Ok(Ok(())) => match response_receiver - .recv_timeout(deadline.saturating_duration_since(Instant::now())) - { - Ok(result) => result, - Err(RecvTimeoutError::Timeout) => Err("插件 RPC 响应超时".to_string()), - Err(RecvTimeoutError::Disconnected) => Err("插件进程已退出".to_string()), - }, + Ok(Ok(())) => { + command_sent = true; + match response_receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(result) => result, + Err(RecvTimeoutError::Timeout) => Err("插件 RPC 响应超时".to_string()), + Err(RecvTimeoutError::Disconnected) => Err("插件进程已退出".to_string()), + } + } Ok(Err(error)) => Err(error), Err(_) => { self.terminate_rpc_instance(id, &pending); @@ -1284,6 +1313,16 @@ impl PluginHost { if let Ok(mut pending) = pending.lock() { pending.remove(&request_id); } + if unity_execute + && command_sent + && !result.as_ref().is_ok_and(|value| { + crate::editor_adapters::unity_execute_receipt_is_valid(value) + && value["status"] != "needs-reconciliation" + }) + { + // Unity 已知结果到 JS / 调用者的最后一跳丢失同样不可通过重载插件重试。 + let _ = crate::runner::mark_external_unity_editor_uncertain(); + } audit( &root, id, @@ -1555,32 +1594,39 @@ impl PluginHost { } "host.rpc" => { require_plugin_adapter(&manifest.id, editors)?; - let input: EditorRpcInput = descriptor_from_params(params)?; - if manifest.id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID - && !active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref() - .is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }) - { - return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); + if crate::builtin_plugins::toggle_state(&manifest.id) == Some(false) { + return Err("插件已禁用".to_string()); } - let adapter = input + let input: EditorRpcInput = descriptor_from_params(params)?; + let adapter = manifest .adapter - .or_else(|| manifest.adapter.clone()) + .as_deref() .ok_or_else(|| "插件未指定编辑器适配器".to_string())?; + if input + .adapter + .as_deref() + .is_some_and(|requested| requested != adapter) + { + return Err("插件不能覆盖 manifest 声明的编辑器适配器".to_string()); + } + // 不在全局锁后排队,避免外层已超时的写操作稍后才派发。 let editors = editors - .lock() - .map_err(|_| "编辑器注册表锁已损坏".to_string())?; + .try_lock() + .map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?; let editor = editors - .get(&adapter) + .get(adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))?; - editor.rpc(&input.method, input.params) + let project = active_project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())?; + if !plugin_matches_project(&manifest.id, project.as_deref()) { + return Err("编辑器插件与当前项目类型不匹配".to_string()); + } + let project = project + .as_deref() + .ok_or_else(|| "尚未设置当前项目".to_string())?; + let params = controlled_editor_params(project, input.params)?; + editor.rpc(&input.method, params) } _ => Err(format!("宿主不支持 RPC 方法:{method}")), } @@ -1640,21 +1686,20 @@ impl PluginHost { .map_err(|_| "项目目录不可读".to_string()) }) .transpose()?; - let is_cocos_project = project.as_deref().is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }); + let previous = state + .active_project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())? + .clone(); + if previous != project { + crate::editor_adapters::disconnect_unity_editor_connection(); + } *state .active_project .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? = project; - if !is_cocos_project { - if let Some(record) = state - .plugins - .get_mut(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) - { + .map_err(|_| "项目上下文锁已损坏".to_string())? = project.clone(); + for record in state.plugins.values_mut() { + if !plugin_matches_project(&record.id, project.as_deref()) { if let Some(mut running) = record.running.take() { let _ = running.child.kill(); let _ = running.child.wait(); @@ -1706,7 +1751,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; if editors.contains_key(adapter.id()) { return Err("编辑器适配器已注册".to_string()); @@ -1726,7 +1771,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get(&adapter) @@ -1747,7 +1792,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get_mut(&adapter) @@ -1762,7 +1807,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get_mut(&adapter) @@ -1783,7 +1828,7 @@ impl PluginHost { .map_err(|_| "插件宿主锁已损坏".to_string())?; let editors = state .editors - .lock() + .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get(&adapter) @@ -1871,11 +1916,15 @@ pub(crate) fn read_agc_plugin_panel( } #[tauri::command] -pub(crate) fn set_agc_plugin_project_path( +pub(crate) async fn set_agc_plugin_project_path( project_path: Option, - host: State<'_, PluginHost>, + app: tauri::AppHandle, ) -> Result<(), String> { - host.set_active_project(project_path) + tauri::async_runtime::spawn_blocking(move || { + app.state::().set_active_project(project_path) + }) + .await + .map_err(|_| "切换插件项目上下文任务失败".to_string())? } #[cfg(test)] @@ -2096,8 +2145,146 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p assert!(read_bounded_rpc_line(&mut reader).is_err()); } + #[test] + fn editor_rpc_enforces_manifest_adapter_and_current_project_without_queueing() { + let root = tempdir().unwrap(); + let other = tempdir().unwrap(); + let project = Arc::new(Mutex::new(Some(root.path().to_path_buf()))); + let editors: EditorRegistry = Arc::new(Mutex::new(BTreeMap::from([( + "cocos-editor".to_string(), + Box::new(StubCocosAdapter) as Box, + )]))); + let registrations = Arc::new(Mutex::new(PluginRegistrations::default())); + let mut manifest = manifest(); + manifest.adapter = Some("cocos-editor".to_string()); + manifest.permissions.insert("editor.rpc".to_string()); + let call = |params| { + PluginHost::handle_host_request( + root.path(), + &project, + &editors, + &manifest, + ®istrations, + "host.rpc", + Some(params), + ) + }; + assert!(call(json!({"adapter":"unity-editor","method":"editor.execute","params":{"code":"return 1;"}})).unwrap_err().contains("manifest")); + assert!(call(json!({"method":"editor.execute","params":{"projectPath":other.path(),"code":"return 1;"}})).unwrap_err().contains("受控项目")); + let result = + call(json!({"method":"editor.execute","params":{"code":"return 1;"}})).unwrap(); + assert_eq!( + result["params"]["projectPath"], + root.path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + let _busy = editors.lock().unwrap(); + let start = std::time::Instant::now(); + assert!(call(json!({"method":"editor.execute","params":{"code":"return 1;"}})).is_err()); + assert!(start.elapsed() < Duration::from_millis(100)); + } + struct StubCocosAdapter; + struct StubUnityAdapter; + + impl EditorAdapter for StubUnityAdapter { + fn id(&self) -> &'static str { + "unity-editor" + } + fn detect(&self, _project_path: &Path) -> Result { + Ok(EditorConnectionInfo::disconnected("unity-editor")) + } + fn connect( + &mut self, + _pid: u32, + _project_path: &Path, + _version: &str, + ) -> Result { + Err("test does not connect".to_string()) + } + fn disconnect(&mut self) {} + fn translate_rpc(&self, _method: &str, params: Value) -> Result { + Ok(params) + } + fn rpc(&self, _method: &str, params: Value) -> Result { + Ok( + json!({"ok":true,"status":"completed","retryAllowed":false,"dispatched":true,"result":{"code":params["code"],"projectPath":params["projectPath"]}}), + ) + } + } + + #[test] + fn workspace_unity_plugin_round_trips_and_stops_when_leaving_project() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempdir().unwrap(); + let project = tempdir().unwrap(); + for directory in ["Assets", "Packages", "ProjectSettings"] { + fs::create_dir(project.path().join(directory)).unwrap(); + } + fs::write( + project.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.1f1", + ) + .unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let host = PluginHost::default(); + host.initialize(config.path()).unwrap(); + host.register_editor_adapter(Box::new(StubUnityAdapter)) + .unwrap(); + host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) + .unwrap(); + assert!(!host + .list() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-unity-editor")); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .unwrap(); + host.start("agc-unity-editor").unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if host.list().unwrap().iter().any(|plugin| { + plugin.id == "agc-unity-editor" + && plugin.commands.len() == 1 + && plugin.capabilities.len() == 1 + }) { + break; + } + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(20)); + } + let response = host + .call( + "agc-unity-editor", + "unity.editor.execute".to_string(), + json!({"code":"return 2;"}), + ) + .unwrap(); + assert_eq!(response["status"], "completed"); + assert_eq!( + response["result"]["projectPath"], + project + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + host.set_active_project(None).unwrap(); + assert!(!host + .list() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-unity-editor")); + assert!(host.state.lock().unwrap().plugins["agc-unity-editor"] + .running + .is_none()); + } + impl EditorAdapter for StubCocosAdapter { fn id(&self) -> &'static str { "cocos-editor" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 1e634bbda..f9704c49a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -36,6 +36,7 @@ pub(crate) fn list_local_project_files_at( * 前端资源树加载一堆永远用不上的条目。 */ let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some(); + let is_unity_project = discover_local_unity_project_root(root)?.is_some(); let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { @@ -66,6 +67,15 @@ pub(crate) fn list_local_project_files_at( .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) .unwrap_or(0); if file_type.is_dir() { + if is_unity_project + && !relative_path.contains('/') + && matches!( + relative_path.to_ascii_lowercase().as_str(), + "library" | "temp" | "obj" | "logs" | "usersettings" + ) + { + continue; + } if skip_engine_generated_directories && is_engine_generated_root_directory(&relative_path) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 4f8b0c793..e49073ac3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -181,6 +181,33 @@ fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), Strin Ok(()) } +/// Unity 工程身份来自根目录普通文件和目录;禁止借助链接跳到其它工程。 +pub(crate) fn discover_local_unity_project_root(root: &Path) -> Result, String> { + if root.as_os_str().is_empty() || !root.is_absolute() || project_path_has_control_chars(root) { + return Err("Unity 项目目录必须是无控制字符的绝对路径".to_string()); + } + for (path, directory) in [ + (root.to_path_buf(), true), + (root.join("Assets"), true), + (root.join("Packages"), true), + (root.join("ProjectSettings"), true), + (root.join("ProjectSettings/ProjectVersion.txt"), false), + ] { + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("读取 Unity 项目结构失败:{error}")), + }; + if godot_metadata_is_link(&metadata) + || (directory && !metadata.is_dir()) + || (!directory && !metadata.is_file()) + { + return Ok(None); + } + } + Ok(Some(".".to_string())) +} + pub(crate) fn discover_local_cocos_project_root( workspace_root: &Path, ) -> Result, String> { @@ -763,6 +790,49 @@ pub(crate) fn import_local_cocos_project_at( }) } +pub(crate) fn import_local_unity_project_at( + root: &Path, + project_id: &str, + name: &str, +) -> Result { + discover_local_unity_project_root(root)?.ok_or_else(|| + "所选目录不是有效的 Unity 项目(需要 Assets、Packages 和 ProjectSettings/ProjectVersion.txt)".to_string())?; + if project_id.is_empty() { + return Err("项目 ID 不能为空".to_string()); + } + let name = normalize_game_creation_project_name(name)?; + prepare_game_creator_project_root_for_read(root, true, "Unity 工作区目录")?; + let manifest_path = root.join(".agent/manifest.json"); + if manifest_storage_exists(&manifest_path)? { + return Ok(InitLocalProjectResult { + project_path: root.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + manifest: read_manifest(&manifest_path)?, + }); + } + if !root.join(".agent/agent.db").exists() { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.import", "projectId": project_id, + "name": name, "projectKind": "unity", "unityProjectRoot": ".", + }), + )?; + } + for relative in [".agent/logs", ".agent/runtime"] { + let path = root.join(relative); + ensure_game_creator_private_directory_tree(&path, "Unity 项目 Agent 目录")?; + prepare_game_creator_private_path_for_read(&path, true, "Unity 项目 Agent 目录")?; + } + let manifest = new_game_creation_app_manifest(project_id, name); + write_manifest(&manifest_path, &manifest)?; + Ok(InitLocalProjectResult { + project_path: root.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + manifest, + }) +} + pub(crate) fn record_preview_state( root: &Path, status: GameCreationAppPreviewStatus, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 71fd5bf4c..c3a15f007 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -23,6 +23,10 @@ pub(crate) use client::{ steer_external_agent_runner, wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, }; +pub(crate) use client::{ + call_external_unity_editor, disconnect_external_unity_editor, + mark_external_unity_editor_uncertain, +}; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index e27a07792..f0e924543 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -509,6 +509,7 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( pub(super) fn external_agent_runner_client_read_timeout(method: &str) -> Duration { match method { "runtime.compact" => EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT, + "unity.editor.rpc" => Duration::from_secs(80), _ => EXTERNAL_AGENT_RUNNER_IO_TIMEOUT, } } @@ -1648,6 +1649,7 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( }; let params = ExternalAgentRunnerRequestParams { root: Some(root.to_string()), + editor_rpc: None, agent: agent.map(str::to_string), session_id: None, run_id: run_id.map(str::to_string), @@ -1678,6 +1680,185 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( } } +pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Result { + let deadline = Instant::now() + Duration::from_secs(80); + static EXECUTION_UNCERTAIN: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?; + let uncertain_result = || serde_json::json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"runner-receipt-unconfirmed","message":"Unity 执行回执未确认,核对后退出全部 AGC 和 Runner 再重新打开"}}); + if method == "execute" && EXECUTION_UNCERTAIN.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(uncertain_result()); + } + if method == "execute" + && crate::editor_adapters::unity_uncertain_fence_path(&config_dir).exists() + { + return Ok(uncertain_result()); + } + let endpoint = if crate::editor_adapters::unity_execution_fence_path(&config_dir).exists() { + // 在途 fence 可能只是正常并发;由活着的 owner 区分 busy 与 unknown。 + // 此分支绝不自动重启 Runner,以免丢失未确认执行的进程内状态。 + match read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir)) + { + Ok(endpoint) => endpoint, + Err(_) if method == "execute" => return Ok(uncertain_result()), + Err(error) => return Err(error), + } + } else { + let _configure = match external_agent_runner_configure_lock().try_lock() { + Ok(guard) => guard, + Err(_) if method == "execute" => { + return Ok(crate::editor_adapters::unity_not_dispatched( + "Runner 正在配置,请等待当前操作完成", + )) + } + Err(_) => return Err("Runner 正在配置,请等待当前操作完成".to_string()), + }; + ensure_external_agent_runner(&config_dir)? + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining < Duration::from_secs(16) { + return if method == "execute" { + Ok(crate::editor_adapters::unity_not_dispatched( + "Unity 调用启动预算已耗尽,未派发执行", + )) + } else { + Err("Unity 调用启动预算已耗尽".to_string()) + }; + } + if let Some(params) = params.as_object_mut() { + if params.get("timeoutMs").is_some_and(|value| { + !value + .as_u64() + .is_some_and(|timeout| (1..=60_000).contains(&timeout)) + }) { + return if method == "execute" { + Ok(crate::editor_adapters::unity_not_dispatched( + "timeoutMs 必须在 1..=60000", + )) + } else { + Err("timeoutMs 必须在 1..=60000".to_string()) + }; + } + let requested = params + .get("timeoutMs") + .and_then(Value::as_u64) + .unwrap_or(60_000); + let bounded = requested.min(remaining.as_millis().saturating_sub(15_000) as u64); + params.insert("timeoutMs".to_string(), serde_json::json!(bounded)); + } + let request_id = random_identifier(b"agc-unity-editor-request")?; + let request_params = ExternalAgentRunnerRequestParams { + editor_rpc: Some( + serde_json::json!({"method":method,"params":params,"deadlineMs":unix_millis()+remaining.as_millis() as u64}), + ), + ..Default::default() + }; + // 只发送一次,传输失败不能重新派发 execute。 + let response = send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + &endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id.clone(), + "unity.editor.rpc", + request_params, + Duration::from_secs(2), + remaining.saturating_sub(Duration::from_secs(7)), + Duration::from_secs(2), + ); + match response { + Ok(mut value) => { + if method == "execute" { + if !crate::editor_adapters::unity_execute_receipt_is_valid(&value) { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + return Ok(uncertain_result()); + } + if value["status"] == "needs-reconciliation" { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + return Ok(value); + } + let Some(ack_required) = value.get("ackRequired").and_then(Value::as_bool) else { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir); + return Ok(uncertain_result()); + }; + if let Some(object) = value.as_object_mut() { + object.remove("ackRequired"); + } + if !ack_required { + return Ok(value); + } + if Instant::now() + Duration::from_secs(3) >= deadline { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + return Ok(uncertain_result()); + } + let acknowledgement = + send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + &endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + random_identifier(b"agc-unity-ack")?, + "unity.editor.ack", + ExternalAgentRunnerRequestParams { + editor_rpc: Some(serde_json::json!({"requestId":request_id})), + ..Default::default() + }, + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_millis(500), + ); + if !acknowledgement.is_ok_and(|response| response["acknowledged"] == true) { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir); + return Ok(uncertain_result()); + } + } + Ok(value) + } + Err(_) if method == "execute" => { + EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(uncertain_result()) + } + Err(error) => Err(error), + } +} + +pub(crate) fn disconnect_external_unity_editor() -> Result<(), String> { + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(()); + }; + let path = external_agent_runner_endpoint_path(&config_dir); + if !path.exists() { + return Ok(()); + } + let endpoint = read_external_agent_runner_endpoint(&path)?; + send_external_agent_runner_request( + &endpoint, + "unity.editor.rpc", + ExternalAgentRunnerRequestParams { + editor_rpc: Some(serde_json::json!({"method":"disconnect","params":{}})), + ..Default::default() + }, + ) + .map(|_| ()) +} + +pub(crate) fn mark_external_unity_editor_uncertain() -> Result<(), String> { + let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?; + // 先保存 GUI 与 Runner 共享的单向 fence;网络丢失也不能解锁。 + crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir)?; + let endpoint = + read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir))?; + send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + &endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + random_identifier(b"agc-unity-mark-uncertain")?, + "unity.editor.mark_uncertain", + ExternalAgentRunnerRequestParams::default(), + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_millis(500), + ) + .map(|_| ()) +} + pub(crate) fn compact_external_agent_runner_context( root: &Path, agent: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 3186fd863..62659b134 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -236,7 +236,7 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( } fn external_agent_runner_method_requires_current_gui_owner_claim(method: &str) -> bool { - method.starts_with("runtime.") + method.starts_with("runtime.") || method.starts_with("unity.editor.") } pub(super) fn external_agent_runner_request_session_id( @@ -1031,6 +1031,103 @@ pub(super) fn handle_external_agent_runner_request( } match request.method.as_str() { + // 编辑器使用自身的有界并发门闩;不能持有 Runtime 全局写请求缓存锁等待 Unity。 + "unity.editor.rpc" => { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct EditorCall { + method: String, + params: serde_json::Value, + #[serde(default)] + deadline_ms: Option, + } + let result = (|| { + if state.draining.load(Ordering::Acquire) { + return Err("Agent Runner 正在退出".to_string()); + } + let call: EditorCall = serde_json::from_value( + request.params.editor_rpc.clone().ok_or("缺少 editorRpc")?, + ) + .map_err(|_| "Unity RPC 参数无效".to_string())?; + if call + .deadline_ms + .is_some_and(|deadline| deadline <= unix_millis()) + { + return Err("Unity RPC 派发期限已过,未发送执行".to_string()); + } + crate::editor_adapters::unity_editor_rpc_owned( + &call.method, + call.params, + Some(&request.request_id), + ) + })(); + match result { + Ok(mut value) => { + if request + .params + .editor_rpc + .as_ref() + .and_then(|value| value["method"].as_str()) + == Some("execute") + { + if value.get("ackRequired").is_none() { + value["ackRequired"] = json!(false); + } + } + ExternalAgentRunnerResponse::success(&request.request_id, value) + } + Err(error) + if request + .params + .editor_rpc + .as_ref() + .and_then(|value| value["method"].as_str()) + == Some("execute") => + { + let mut value = crate::editor_adapters::unity_not_dispatched(&error); + value["ackRequired"] = json!(false); + ExternalAgentRunnerResponse::success(&request.request_id, value) + } + Err(error) => ExternalAgentRunnerResponse::failure( + &request.request_id, + "unity-editor-failed", + error, + ), + } + } + "unity.editor.ack" => { + let result = request + .params + .editor_rpc + .as_ref() + .and_then(|value| value["requestId"].as_str()) + .ok_or_else(|| "缺少 Unity 回执身份".to_string()) + .and_then(crate::editor_adapters::acknowledge_unity_editor_delivery); + match result { + Ok(()) => ExternalAgentRunnerResponse::success( + &request.request_id, + json!({"acknowledged":true}), + ), + Err(error) => ExternalAgentRunnerResponse::failure( + &request.request_id, + "unity-ack-failed", + error, + ), + } + } + "unity.editor.mark_uncertain" => { + match crate::editor_adapters::mark_unity_execution_uncertain() { + Ok(()) => ExternalAgentRunnerResponse::success( + &request.request_id, + json!({"status":"needs-reconciliation"}), + ), + Err(error) => ExternalAgentRunnerResponse::failure( + &request.request_id, + "unity-mark-failed", + error, + ), + } + } "runner.ping" => ExternalAgentRunnerResponse::success( &request.request_id, json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index de3ade0ae..b57eddf78 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -1022,6 +1022,23 @@ pub(crate) fn acquire_external_agent_runner_gui_participant_lock( config_dir: &Path, ) -> Result { let path = external_agent_runner_gui_participant_lock_path(config_dir); + // 恢复 Unity 未确认执行只允许在全部 GUI 和 Runner 退出后的首次打开。 + // 转为共享参与锁期间继续持有 Runner 独占锁,避免转换间隙启动执行 owner。 + let _fresh_gui_runner_guard = if let Some(participant) = + try_open_external_agent_runner_lock(&path, "AGC 界面参与锁")? + { + let runner = try_open_external_agent_runner_lock( + &external_agent_runner_lock_path(config_dir), + "Agent Runner 单实例锁", + )?; + if runner.is_some() { + crate::editor_adapters::reset_unity_execution_fence_for_fresh_gui(config_dir)?; + } + drop(participant); + runner + } else { + None + }; let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT; let mut last_error = "AGC 界面参与锁未知失败".to_string(); loop { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 5f4ae187c..434c1c0a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -24,7 +24,8 @@ pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_PATH: &str = ".agent/runtime/execution-owner.lock"; pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH: &str = ".agent/runtime/execution-owner.json"; -pub(super) const EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES: usize = 1024 * 1024; +// Unity helper 最多返回 2 MiB,额外保留 JSON 转义和 Runner 信封空间。 +pub(super) const EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES: u64 = 64 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_OWNER_BYTES: u64 = 16 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS: usize = 32; @@ -252,6 +253,8 @@ impl ExternalAgentRunnerStatus { #[derive(Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct ExternalAgentRunnerRequestParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) editor_rpc: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) root: Option, #[serde(default, alias = "agentId", skip_serializing_if = "Option::is_none")] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index 998ca0e9f..701699b04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -213,6 +213,7 @@ pub(crate) fn run_external_agent_runner_server( let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release); crate::set_game_creator_runtime_config_dir(config_dir.clone()); + crate::editor_adapters::configure_unity_helper_for_runtime()?; set_external_agent_runner_config_dir(config_dir.clone()); let boot_id = random_identifier(b"genarrative-agent-runner-boot-id")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index f1d2f7a39..5d35f0c71 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -20,6 +20,50 @@ use crate::{ static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0); +#[test] +fn unity_pending_execution_survives_new_window_and_runner_restart_until_full_gui_restart() { + let directory = unique_test_directory(); + let config = private_runner_test_config_dir(&directory); + let first = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + let fence = crate::editor_adapters::unity_execution_fence_path(&config); + fs::write(&fence, "unknown-request").unwrap(); + crate::editor_adapters::mark_unity_execution_uncertain_at(&config).unwrap(); + let uncertain_fence = crate::editor_adapters::unity_uncertain_fence_path(&config); + let second = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + assert!( + fence.exists(), + "new window must not clear pending execution" + ); + drop(second); + let runner = acquire_external_agent_runner_instance_lock( + &external_agent_runner_lock_path(&config), + "unity-test-boot", + ) + .unwrap(); + drop(first); + let while_runner_alive = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + assert!(fence.exists(), "running owner prevents recovery"); + drop(while_runner_alive); + drop(runner); + let restarted_runner = acquire_external_agent_runner_instance_lock( + &external_agent_runner_lock_path(&config), + "unity-test-boot-2", + ) + .unwrap(); + assert!( + fence.exists(), + "automatic Runner restart must not clear pending execution" + ); + assert!(uncertain_fence.exists()); + drop(restarted_runner); + let _fresh = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + assert!( + !fence.exists(), + "all GUI and Runner exited: a fresh GUI can recover" + ); + assert!(!uncertain_fence.exists()); +} + struct TestDirectoryGuard(PathBuf); impl Drop for TestDirectoryGuard { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5aedeadd0..75b8d22db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2292,6 +2292,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { godot_project_root: None, is_cocos_project: false, cocos_project_root: None, + is_unity_project: false, + unity_project_root: None, project_name: None, modified_at: None, manifest_error: None, @@ -2393,6 +2395,66 @@ fn project_directory_status_detects_cocos_creator_project() { assert_eq!(detected.as_deref(), Some(".")); } +#[test] +fn unity_project_import_preserves_engine_files_and_existing_agent_metadata() { + let root = tempfile::tempdir().unwrap(); + for directory in ["Assets", "Packages", "ProjectSettings"] { + fs::create_dir(root.path().join(directory)).unwrap(); + } + fs::write( + root.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.1f1\n", + ) + .unwrap(); + fs::write(root.path().join("Assets/keep.txt"), "用户资源").unwrap(); + fs::write( + root.path().join("Packages/manifest.json"), + "{\"dependencies\":{}}", + ) + .unwrap(); + let status = + inspect_local_project_directory_sync(root.path().to_string_lossy().into_owned()).unwrap(); + assert!(status.is_unity_project); + assert_eq!(status.unity_project_root.as_deref(), Some(".")); + let initial = import_local_unity_project_at(root.path(), "unity-test", "Unity项目").unwrap(); + fs::write(root.path().join(".agent/keep.txt"), "用户元数据").unwrap(); + let reopened = import_local_unity_project_at(root.path(), "another-id", "另一个名称").unwrap(); + assert_eq!(initial.manifest.project_id, reopened.manifest.project_id); + assert_eq!( + fs::read_to_string(root.path().join("Assets/keep.txt")).unwrap(), + "用户资源" + ); + assert_eq!( + fs::read_to_string(root.path().join("Packages/manifest.json")).unwrap(), + "{\"dependencies\":{}}" + ); + assert_eq!( + fs::read_to_string(root.path().join(".agent/keep.txt")).unwrap(), + "用户元数据" + ); + assert!(root + .path() + .join("ProjectSettings/ProjectVersion.txt") + .is_file()); +} + +#[test] +fn unity_project_identity_requires_all_engine_directories() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("ProjectSettings")).unwrap(); + fs::write( + root.path().join("ProjectSettings/ProjectVersion.txt"), + "version", + ) + .unwrap(); + assert_eq!( + discover_local_unity_project_root(root.path()).unwrap(), + None + ); + assert!(import_local_unity_project_at(root.path(), "id", "Unity项目").is_err()); + assert!(!root.path().join(".agent").exists()); +} + #[test] fn project_directory_status_reports_workspace_relative_godot_root() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 16fb765b0..211a03ab3 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -614,23 +614,31 @@ export function App({ localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 if (!nextProjectPath && !previousProjectPath) return; + let active = true; + const editorPlugin = + workspaceProjectKind === 'cocos' + ? { id: 'agc-cocos-editor', title: 'Cocos Creator' } + : workspaceProjectKind === 'unity' + ? { id: 'agc-unity-editor', title: 'Unity' } + : null; void setAgcPluginProjectPath(nextProjectPath) .then(async () => { - if (workspaceProjectKind === 'cocos' && nextProjectPath) { - await startAvailableAgcPlugin('agc-cocos-editor'); + if (active && editorPlugin && nextProjectPath) { + await startAvailableAgcPlugin(editorPlugin.id); } }) .catch((error) => { - if (workspaceProjectKind !== 'cocos' || !nextProjectPath) { + if (!active || !editorPlugin || !nextProjectPath) { return; } setWorkspaceStatus( - `Cocos Creator 插件未就绪:${ + `${editorPlugin.title} 插件未就绪:${ error instanceof Error ? error.message : String(error) }`, ); }); return () => { + active = false; if (nextProjectPath || previousProjectPath) { void setAgcPluginProjectPath(null).catch(() => undefined); } @@ -1811,9 +1819,7 @@ export function App({ void openWorkspace( initialProjectPath, false, - initialProjectKind === 'godot' || initialProjectKind === 'cocos' - ? 'open' - : 'create', + initialProjectKind !== 'web' ? 'open' : 'create', initialProjectKind, ); // Initial project opening is guarded by initialProjectOpenedRef. diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index dd40ab154..75fbc956f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -128,7 +128,7 @@ export type AgcExtensionSummary = { clientExtension: ClientExtensionItem | null; }; -export type LocalProjectKind = 'web' | 'godot' | 'cocos'; +export type LocalProjectKind = 'web' | 'godot' | 'cocos' | 'unity'; export type ProjectStartMode = 'planning' | 'direct-build'; @@ -184,6 +184,8 @@ export interface LocalProjectDirectoryStatus { godotProjectRoot: string | null; isCocosProject?: boolean; cocosProjectRoot?: string | null; + isUnityProject?: boolean; + unityProjectRoot?: string | null; projectName: string | null; modifiedAt?: number | null; manifestError?: string | null; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index fcaef751c..44dd62ff5 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -22,6 +22,7 @@ import type { AgentRuntimeTaskRecord, ChatMessage, LocalConversationMessageRecord, + LocalProjectKind, TauriInvoke, } from '../../app/types'; const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-'; @@ -40,7 +41,7 @@ export function resolveProjectSupervisorRuntimeSubmission({ supervisorChatOnly, planningEntry = false, }: { - workspaceProjectKind: 'web' | 'godot' | 'cocos'; + workspaceProjectKind: LocalProjectKind; orchestrationMode: 'single-supervisor' | 'professional-dag'; supervisorChatOnly: boolean; planningEntry?: boolean; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index 69ae5d245..c6b9a06c1 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -324,7 +324,8 @@ export function ProjectsPage({ > {project.projectKind === 'godot' || - project.projectKind === 'cocos' ? ( + project.projectKind === 'cocos' || + project.projectKind === 'unity' ? (