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
282 lines
8.6 KiB
JavaScript
282 lines
8.6 KiB
JavaScript
/**
|
|
* AGC Unity 插件的 agc.plugin.v1 入口。
|
|
* 与内置 Cocos 插件相同,直接运行的 JS 使用 SDK 的宿主协议;全部编辑器副作用
|
|
* 经 host.rpc 交给统一原生服务,入口不查找进程、不启动 helper、不持有凭据。
|
|
*/
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
export const UNITY_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1';
|
|
export const UNITY_EXECUTE_COMMAND_ID = 'unity.editor.execute';
|
|
export const UNITY_CONNECTION_CAPABILITY_ID = 'unity.editor.connection';
|
|
const MAX_MESSAGE_BYTES = 2 * 1024 * 1024;
|
|
const MAX_CODE_BYTES = 128 * 1024;
|
|
|
|
export function createUnityEditorPlugin({ send, timeoutMs = 85_000 }) {
|
|
let nextId = 1;
|
|
let activeProjectPath = null;
|
|
let projectEpoch = 0;
|
|
let disposed = false;
|
|
let executing = false;
|
|
let uncertain = false;
|
|
const pending = new Map();
|
|
|
|
function request(method, params) {
|
|
if (disposed) return Promise.reject(new Error('插件已停止'));
|
|
const id = nextId++;
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pending.delete(id);
|
|
reject(new Error('Unity 插件宿主 RPC 超时'));
|
|
}, timeoutMs);
|
|
pending.set(id, { resolve, reject, timer });
|
|
try {
|
|
send({ jsonrpc: '2.0', id, method, params });
|
|
} catch (error) {
|
|
clearTimeout(timer);
|
|
pending.delete(id);
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function editorParams(input, allowed) {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
throw new Error('Unity 参数必须是对象');
|
|
}
|
|
for (const key of Object.keys(input)) {
|
|
if (!allowed.includes(key)) throw new Error(`Unity 不接受参数:${key}`);
|
|
}
|
|
if (!activeProjectPath) throw new Error('当前没有受控 Unity 项目');
|
|
if (
|
|
input.timeoutMs !== undefined &&
|
|
(!Number.isInteger(input.timeoutMs) ||
|
|
input.timeoutMs < 1 ||
|
|
input.timeoutMs > 60_000)
|
|
) {
|
|
throw new Error('timeoutMs 必须在 1..60000 之间');
|
|
}
|
|
return { ...input, projectPath: activeProjectPath };
|
|
}
|
|
|
|
function reconcile(error) {
|
|
uncertain = true;
|
|
return {
|
|
ok: false,
|
|
status: 'needs-reconciliation',
|
|
retryAllowed: false,
|
|
dispatched: true,
|
|
error: {
|
|
code: 'execution-uncertain',
|
|
message: typeof error === 'string' ? error : 'Unity 执行结果待核对',
|
|
},
|
|
};
|
|
}
|
|
|
|
async function execute(input) {
|
|
const params = editorParams(input, ['code', 'timeoutMs']);
|
|
if (
|
|
typeof params.code !== 'string' ||
|
|
!params.code.trim() ||
|
|
params.code.includes('\0') ||
|
|
Buffer.byteLength(params.code) > MAX_CODE_BYTES
|
|
) {
|
|
throw new Error('Unity C# 代码必须非空、不含 NUL 且不超过 128 KiB');
|
|
}
|
|
if (uncertain)
|
|
return reconcile('先前执行结果待核对,请核对 Unity 后重启客户端');
|
|
if (executing) {
|
|
return {
|
|
ok: false,
|
|
status: 'failed',
|
|
dispatched: false,
|
|
retryAllowed: false,
|
|
error: { code: 'editor-busy', message: 'Unity 已有执行正在处理' },
|
|
};
|
|
}
|
|
executing = true;
|
|
try {
|
|
const result = await request('host.rpc', {
|
|
method: 'editor.execute',
|
|
params,
|
|
});
|
|
if (result?.status === 'needs-reconciliation')
|
|
return reconcile(result.error?.message);
|
|
const validError =
|
|
typeof result?.error?.code === 'string' &&
|
|
result.error.code.trim() &&
|
|
typeof result?.error?.message === 'string' &&
|
|
result.error.message.trim();
|
|
if (
|
|
!result ||
|
|
typeof result.ok !== 'boolean' ||
|
|
typeof result.dispatched !== 'boolean' ||
|
|
result.retryAllowed !== false ||
|
|
!(
|
|
(result.status === 'completed' &&
|
|
result.ok &&
|
|
result.dispatched &&
|
|
Object.hasOwn(result, 'result')) ||
|
|
(result.status === 'failed' && !result.ok && validError)
|
|
)
|
|
) {
|
|
return reconcile('Unity 执行回执无效');
|
|
}
|
|
return result;
|
|
} catch {
|
|
return reconcile('Unity 执行连接中断或超时,结果待核对');
|
|
} finally {
|
|
executing = false;
|
|
}
|
|
}
|
|
|
|
async function connection(input = {}) {
|
|
const params = editorParams(input, ['operation', 'processId', 'timeoutMs']);
|
|
const operation = params.operation ?? 'detect';
|
|
if (!['detect', 'connect', 'status', 'disconnect'].includes(operation)) {
|
|
throw new Error('不支持的 Unity 连接操作');
|
|
}
|
|
if (
|
|
params.processId !== undefined &&
|
|
(!Number.isInteger(params.processId) || params.processId <= 0)
|
|
) {
|
|
throw new Error('processId 必须是正整数');
|
|
}
|
|
delete params.operation;
|
|
return request('host.rpc', { method: `editor.${operation}`, params });
|
|
}
|
|
|
|
async function handleMessage(message) {
|
|
if (disposed) return;
|
|
const value = typeof message === 'string' ? JSON.parse(message) : message;
|
|
if (!value || value.jsonrpc !== '2.0') return;
|
|
if (value.method === 'host.event') {
|
|
if (value.params?.type === 'project.changed') {
|
|
const project = value.params.payload?.projectPath;
|
|
activeProjectPath =
|
|
typeof project === 'string' && project ? project : null;
|
|
projectEpoch += 1;
|
|
}
|
|
return;
|
|
}
|
|
if (value.method !== undefined) {
|
|
if (value.id === undefined) return;
|
|
try {
|
|
const handler =
|
|
value.method === UNITY_EXECUTE_COMMAND_ID
|
|
? execute
|
|
: value.method === UNITY_CONNECTION_CAPABILITY_ID
|
|
? connection
|
|
: null;
|
|
if (!handler) throw new Error('插件未注册该方法');
|
|
const result = await handler(value.params ?? {});
|
|
if (!disposed) send({ jsonrpc: '2.0', id: value.id, result });
|
|
} catch (error) {
|
|
if (!disposed) {
|
|
if (value.method === UNITY_EXECUTE_COMMAND_ID) {
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id: value.id,
|
|
result: {
|
|
ok: false,
|
|
status: 'failed',
|
|
retryAllowed: false,
|
|
dispatched: false,
|
|
error: { code: 'invalid-input', message: error.message },
|
|
},
|
|
});
|
|
} else {
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id: value.id,
|
|
error: { code: -32602, message: error.message },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
const item = pending.get(value.id);
|
|
if (!item) return;
|
|
pending.delete(value.id);
|
|
clearTimeout(item.timer);
|
|
if (value.error) item.reject(new Error('插件宿主拒绝请求'));
|
|
else item.resolve(value.result);
|
|
}
|
|
|
|
async function start() {
|
|
await request('host.registerCommand', {
|
|
id: UNITY_EXECUTE_COMMAND_ID,
|
|
title: '执行 Unity C#',
|
|
description: '在当前 Unity 项目的编辑器主线程执行 C#',
|
|
});
|
|
await request('host.registerCapability', {
|
|
id: UNITY_CONNECTION_CAPABILITY_ID,
|
|
description: '当前 Unity 项目的编辑器连接与状态',
|
|
});
|
|
const epoch = projectEpoch;
|
|
const result = await request('host.events.subscribe', {
|
|
type: 'project.changed',
|
|
});
|
|
if (epoch === projectEpoch) activeProjectPath = result?.projectPath ?? null;
|
|
}
|
|
|
|
function dispose() {
|
|
disposed = true;
|
|
for (const item of pending.values()) {
|
|
clearTimeout(item.timer);
|
|
item.reject(new Error('插件已停止'));
|
|
}
|
|
pending.clear();
|
|
}
|
|
return { start, handleMessage, dispose };
|
|
}
|
|
|
|
export function startUnityEditorStdioPlugin({
|
|
stdin = process.stdin,
|
|
stdout = process.stdout,
|
|
} = {}) {
|
|
const plugin = createUnityEditorPlugin({
|
|
send(message) {
|
|
const line = `${JSON.stringify(message)}\n`;
|
|
if (Buffer.byteLength(line) > MAX_MESSAGE_BYTES)
|
|
throw new Error('插件消息过大');
|
|
stdout.write(line);
|
|
},
|
|
});
|
|
let buffer = '';
|
|
let stopped = false;
|
|
const stop = () => {
|
|
stopped = true;
|
|
buffer = '';
|
|
plugin.dispose();
|
|
stdin.pause();
|
|
};
|
|
stdin.setEncoding('utf8');
|
|
stdin.on('data', (chunk) => {
|
|
if (stopped) return;
|
|
buffer += chunk;
|
|
let boundary;
|
|
while ((boundary = buffer.indexOf('\n')) >= 0) {
|
|
const line = buffer.slice(0, boundary);
|
|
buffer = buffer.slice(boundary + 1);
|
|
if (Buffer.byteLength(line) > MAX_MESSAGE_BYTES) {
|
|
stop();
|
|
return;
|
|
}
|
|
if (line.trim()) void plugin.handleMessage(line).catch(stop);
|
|
}
|
|
if (Buffer.byteLength(buffer) > MAX_MESSAGE_BYTES) stop();
|
|
});
|
|
stdin.on('end', stop);
|
|
stdin.on('error', stop);
|
|
void plugin.start().catch(stop);
|
|
return plugin;
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
) {
|
|
startUnityEditorStdioPlugin();
|
|
}
|