33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。 ## 主要变更 - 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。 - GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。 - 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。 - Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。 ## 验证 - .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。 - Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。 - 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。 Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
493 lines
19 KiB
Rust
493 lines
19 KiB
Rust
#[path = "build_support/codex_bundle.rs"]
|
||
mod codex_bundle;
|
||
#[path = "build_support/frontend_dist_guard.rs"]
|
||
mod frontend_dist_guard;
|
||
#[path = "build_support/runtime_prompt_bundle.rs"]
|
||
mod runtime_prompt_bundle;
|
||
|
||
use sha2::{Digest, Sha256};
|
||
use std::collections::BTreeSet;
|
||
use std::env;
|
||
use std::fs;
|
||
use std::path::PathBuf;
|
||
|
||
use std::io::{BufReader, Read};
|
||
|
||
fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
|
||
let file = fs::File::open(path)?;
|
||
let mut reader = BufReader::new(file);
|
||
let mut hasher = Sha256::new();
|
||
let mut buffer = [0_u8; 64 * 1024];
|
||
loop {
|
||
let read = reader.read(&mut buffer)?;
|
||
if read == 0 {
|
||
break;
|
||
}
|
||
hasher.update(&buffer[..read]);
|
||
}
|
||
Ok(format!("{:x}", hasher.finalize()))
|
||
}
|
||
|
||
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
|
||
let target = env::var("TARGET").expect("Cargo TARGET");
|
||
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
|
||
let Some(layout) = codex_bundle::for_target(&target) else {
|
||
assert!(
|
||
!target.contains("windows") && !target.contains("apple-darwin"),
|
||
"不支持的 Codex 随包目标:{target}"
|
||
);
|
||
return;
|
||
};
|
||
{
|
||
let app_root = manifest_dir
|
||
.parent()
|
||
.expect("AI 游戏创作 Tauri manifest 必须位于应用目录下");
|
||
let repo_root = app_root
|
||
.parent()
|
||
.and_then(|apps_dir| apps_dir.parent())
|
||
.expect("AI 游戏创作应用必须位于仓库 apps 目录下");
|
||
let package = layout.npm_package;
|
||
let source_candidates = [app_root, repo_root]
|
||
.into_iter()
|
||
.flat_map(|root| {
|
||
[
|
||
root.join(format!("node_modules/@openai/{package}/vendor/{target}")),
|
||
root.join(format!(
|
||
"node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}"
|
||
)),
|
||
]
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let source = source_candidates
|
||
.iter()
|
||
.find(|path| {
|
||
layout
|
||
.files
|
||
.iter()
|
||
.all(|relative| path.join(relative).is_file())
|
||
})
|
||
.cloned()
|
||
.unwrap_or_else(|| {
|
||
panic!(
|
||
"内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})",
|
||
source_candidates
|
||
.iter()
|
||
.map(|path| path.display().to_string())
|
||
.collect::<Vec<_>>()
|
||
.join(";")
|
||
)
|
||
});
|
||
let metadata: serde_json::Value = serde_json::from_slice(
|
||
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
|
||
)
|
||
.expect("Codex 原生包元数据无效");
|
||
codex_bundle::validate_package_metadata(&metadata, &target, layout)
|
||
.unwrap_or_else(|error| panic!("{error}"));
|
||
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
|
||
let notice = target_dir.join("NOTICE.md");
|
||
if target.contains("apple-darwin") {
|
||
let source_notice =
|
||
manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md");
|
||
stage_plugin_file(&source_notice, ¬ice);
|
||
println!("cargo:rerun-if-changed={}", source_notice.display());
|
||
}
|
||
if !notice.is_file() {
|
||
panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display());
|
||
}
|
||
fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败");
|
||
let mut file_hashes = serde_json::Map::new();
|
||
for relative in layout.files {
|
||
let source_path = source.join(relative);
|
||
let target_path = target_dir.join(relative);
|
||
if let Some(parent) = target_path.parent() {
|
||
fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败");
|
||
}
|
||
let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败");
|
||
let target_matches_source = target_path.is_file()
|
||
&& sha256_file(&target_path)
|
||
.map(|target_sha256| target_sha256 == source_sha256)
|
||
.unwrap_or(false);
|
||
if !target_matches_source {
|
||
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
|
||
}
|
||
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
|
||
fs::set_permissions(
|
||
&target_path,
|
||
fs::metadata(&source_path)
|
||
.expect("读取组件权限失败")
|
||
.permissions(),
|
||
)
|
||
.expect("保留内置 Codex CLI 组件权限失败");
|
||
file_hashes.insert(
|
||
relative.to_string(),
|
||
serde_json::Value::String(source_sha256),
|
||
);
|
||
}
|
||
let manifest = serde_json::json!({
|
||
"schemaVersion": codex_bundle::SCHEMA,
|
||
"platform": layout.platform,
|
||
"version": codex_bundle::CLI_VERSION,
|
||
"files": file_hashes,
|
||
});
|
||
let manifest_path = target_dir.join("manifest.json");
|
||
let manifest_payload = format!(
|
||
"{}\n",
|
||
serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败")
|
||
);
|
||
if fs::read_to_string(&manifest_path)
|
||
.map(|current| current != manifest_payload)
|
||
.unwrap_or(true)
|
||
{
|
||
fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败");
|
||
}
|
||
for relative in layout.files {
|
||
println!("cargo:rerun-if-changed={}", source.join(relative).display());
|
||
}
|
||
println!("cargo:rerun-if-changed={}", notice.display());
|
||
}
|
||
}
|
||
|
||
fn seed_task_group_id(
|
||
group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup,
|
||
) -> &'static str {
|
||
use shared_contracts::game_creation_app::GameCreationAppAgentGroup;
|
||
match group {
|
||
GameCreationAppAgentGroup::Design => "design",
|
||
GameCreationAppAgentGroup::Art => "art",
|
||
GameCreationAppAgentGroup::Code => "code",
|
||
GameCreationAppAgentGroup::Balance => "balance",
|
||
GameCreationAppAgentGroup::Audio => "audio",
|
||
GameCreationAppAgentGroup::Publishing => "publishing",
|
||
}
|
||
}
|
||
|
||
fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBundle) {
|
||
let actual = compiled
|
||
.specialist_nodes
|
||
.iter()
|
||
.map(|node| {
|
||
(
|
||
node.task_id.clone(),
|
||
node.group_id.clone(),
|
||
node.role.clone(),
|
||
)
|
||
})
|
||
.collect::<BTreeSet<_>>();
|
||
let expected = shared_contracts::game_creation_app::new_game_creation_app_seed_tasks()
|
||
.into_iter()
|
||
.map(|task| {
|
||
(
|
||
task.id,
|
||
seed_task_group_id(&task.group).to_string(),
|
||
task.role,
|
||
)
|
||
})
|
||
.collect::<BTreeSet<_>>();
|
||
if actual != expected {
|
||
panic!(
|
||
"Prompt Bundle agentCatalog 与正式 seed DAG 的 taskId/group/role 不一致\nactual={actual:#?}\nexpected={expected:#?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
fn main() {
|
||
let manifest_dir = PathBuf::from(
|
||
env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"),
|
||
);
|
||
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)
|
||
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
|
||
validate_seed_task_catalog(&compiled);
|
||
for dependency in &compiled.dependencies {
|
||
println!("cargo:rerun-if-changed={}", dependency.display());
|
||
}
|
||
let output_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR must be available"))
|
||
.join("agent_runtime_prompt_bundle.rs");
|
||
fs::write(&output_path, compiled.rust_source)
|
||
.unwrap_or_else(|error| panic!("写入 Prompt Bundle 生成代码失败:{error}"));
|
||
if !tauri_build::is_dev() {
|
||
let frontend_dist = manifest_dir
|
||
.parent()
|
||
.expect("AI 游戏创作 Tauri manifest 必须位于应用目录下")
|
||
.join("dist");
|
||
println!("cargo:rerun-if-changed={}", frontend_dist.display());
|
||
frontend_dist_guard::validate_frontend_dist(&frontend_dist)
|
||
.unwrap_or_else(|error| panic!("生产 frontendDist 检查失败:{error}"));
|
||
}
|
||
tauri_build::build()
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
|
||
if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() {
|
||
return;
|
||
}
|
||
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
|
||
let profile_dir = out_dir
|
||
.ancestors()
|
||
.find(|path| path.file_name().is_some_and(|name| name == "build"))
|
||
.and_then(|build_dir| build_dir.parent())
|
||
.expect("AGC Cargo profile directory not found");
|
||
let candidates = [
|
||
profile_dir.join("deps/cocos_editor_bridge.dll"),
|
||
profile_dir.join("cocos_editor_bridge.dll"),
|
||
];
|
||
let source = candidates
|
||
.iter()
|
||
.find(|path| path.is_file())
|
||
.unwrap_or_else(|| {
|
||
panic!(
|
||
"Cocos bridge native payload 未构建:{}",
|
||
candidates
|
||
.iter()
|
||
.map(|p| p.display().to_string())
|
||
.collect::<Vec<_>>()
|
||
.join(";")
|
||
)
|
||
});
|
||
for destination in [
|
||
// 插件工作区里的 payload 是开发态与打包态的唯一真源。
|
||
manifest_dir
|
||
.join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
|
||
// 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。
|
||
manifest_dir
|
||
.join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
|
||
] {
|
||
std::fs::create_dir_all(destination.parent().expect("payload resource parent"))
|
||
.expect("创建 Cocos bridge payload 目录失败");
|
||
std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败");
|
||
}
|
||
println!("cargo:rerun-if-changed={}", source.display());
|
||
}
|
||
|
||
#[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<PathBuf>) {
|
||
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 源码、
|
||
/// Cargo target 目录或 node_modules。
|
||
fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
|
||
let target = env::var("TARGET").expect("Cargo TARGET");
|
||
if !target.contains("windows") && !target.contains("apple-darwin") {
|
||
return;
|
||
}
|
||
let repo_root = manifest_dir
|
||
.parent()
|
||
.and_then(|app_root| app_root.parent())
|
||
.and_then(|apps_dir| apps_dir.parent())
|
||
.expect("AGC 应用必须位于仓库 apps 目录下")
|
||
.to_path_buf();
|
||
let workspace = repo_root.join("plugins");
|
||
let destination_root = manifest_dir.join("resources/plugins");
|
||
// staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。
|
||
if destination_root.exists() {
|
||
std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败");
|
||
}
|
||
std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
|
||
let entries = match std::fs::read_dir(&workspace) {
|
||
Ok(entries) => entries,
|
||
Err(_) => return,
|
||
};
|
||
for entry in entries.flatten() {
|
||
let plugin_root = entry.path();
|
||
assert!(
|
||
!entry
|
||
.file_type()
|
||
.expect("读取插件目录类型失败")
|
||
.is_symlink(),
|
||
"插件工作区不允许符号链接"
|
||
);
|
||
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
|
||
continue;
|
||
}
|
||
let name = entry.file_name();
|
||
let destination = destination_root.join(&name);
|
||
copy_plugin_file(
|
||
&plugin_root.join("plugin.json"),
|
||
&destination.join("plugin.json"),
|
||
);
|
||
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"))
|
||
|| (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));
|
||
}
|
||
println!("cargo:rerun-if-changed={}", plugin_root.display());
|
||
}
|
||
}
|
||
|
||
fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
|
||
let bytes = std::fs::read(source)
|
||
.unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display()));
|
||
if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
|
||
return;
|
||
}
|
||
if let Some(parent) = destination.parent() {
|
||
std::fs::create_dir_all(parent).expect("创建插件资源目录失败");
|
||
}
|
||
std::fs::write(destination, bytes).expect("复制插件资源失败");
|
||
}
|
||
|
||
fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
|
||
let entries = match std::fs::read_dir(source) {
|
||
Ok(entries) => entries,
|
||
Err(_) => return,
|
||
};
|
||
for entry in entries.flatten() {
|
||
let target = destination.join(entry.file_name());
|
||
let path = entry.path();
|
||
assert!(
|
||
!entry
|
||
.file_type()
|
||
.expect("读取插件文件类型失败")
|
||
.is_symlink(),
|
||
"插件资源不允许符号链接"
|
||
);
|
||
if path.is_dir() {
|
||
let name = entry.file_name();
|
||
let name = name.to_string_lossy();
|
||
if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") {
|
||
continue;
|
||
}
|
||
std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
|
||
copy_plugin_tree(&path, &target);
|
||
} else {
|
||
// 测试文件不随包分发。
|
||
let name = entry.file_name();
|
||
let name = name.to_string_lossy();
|
||
if name.contains(".test.") {
|
||
continue;
|
||
}
|
||
if name.starts_with('.') {
|
||
continue;
|
||
}
|
||
stage_plugin_file(&path, &target);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
|
||
if !source.is_file() {
|
||
return;
|
||
}
|
||
std::fs::create_dir_all(destination.parent().expect("插件资源父目录"))
|
||
.expect("创建插件资源目录失败");
|
||
std::fs::copy(source, destination).expect("复制插件资源失败");
|
||
}
|