Files
Genarrative/plugins/agc-godot-editor/src/entry.mjs
T
kdletters 288e1d4f4a
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 13s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 13s
Project CI / AI game creator shell Rust smoke (push) Failing after 12s
Project CI / AI game creator shell Rust crates (push) Failing after 20s
Project CI / Backend tests (push) Failing after 23s
Project CI / Native shell tests (push) Failing after 12s
Project CI / Frontend tests (push) Failing after 5s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 13s
同步最新主干并修复 Godot 插件启动竞态
合入主干 Rust 1.98.1 工具链及对应 CI 文档更新
Godot 插件先接收受控项目快照再注册命令和连接能力
补充启动竞态回归、失败回执诊断及插件启动合同
2026-09-20 17:46:53 +08:00

302 lines
9.4 KiB
JavaScript

/**
* AGC Godot 插件的 agc.plugin.v1 入口。
* 与内置 Cocos 插件相同,直接运行的 JS 使用 SDK 的宿主协议;全部编辑器副作用
* 经 host.rpc 交给统一 Runner,入口不查找进程、不准备扩展文件、不持有凭据。
*/
import { pathToFileURL } from 'node:url';
export const GODOT_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1';
export const GODOT_EXECUTE_COMMAND_ID = 'godot.editor.execute';
export const GODOT_CONNECTION_CAPABILITY_ID = 'godot.editor.connection';
const MAX_MESSAGE_BYTES = 2 * 1024 * 1024;
const MAX_CODE_BYTES = 128 * 1024;
export function createGodotEditorPlugin({ 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('Godot 插件宿主 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('Godot 参数必须是对象');
}
for (const key of Object.keys(input)) {
if (!allowed.includes(key)) throw new Error(`Godot 不接受参数:${key}`);
}
if (!activeProjectPath) throw new Error('当前没有受控 Godot 项目');
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 : 'Godot 执行结果待核对',
},
};
}
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('Godot GDScript 代码必须非空、不含 NUL 且不超过 128 KiB');
}
if (uncertain)
return reconcile('先前执行结果待核对,请核对 Godot 后重启客户端');
if (executing) {
return {
ok: false,
status: 'failed',
dispatched: false,
retryAllowed: false,
error: { code: 'editor-busy', message: 'Godot 已有执行正在处理' },
};
}
executing = true;
const epoch = projectEpoch;
try {
const result = await request('host.rpc', {
method: 'editor.execute',
params,
});
if (epoch !== projectEpoch)
return reconcile('Godot 执行期间项目已切换,原项目结果待核对');
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, 'error') &&
Object.hasOwn(result, 'result')) ||
(result.status === 'failed' &&
!result.ok &&
validError &&
!Object.hasOwn(result, 'result'))
)
) {
return reconcile('Godot 执行回执无效');
}
return result;
} catch {
return reconcile('Godot 执行连接中断或超时,结果待核对');
} finally {
executing = false;
}
}
async function connection(input = {}) {
const params = editorParams(input, ['operation', 'timeoutMs']);
const operation = params.operation ?? 'detect';
if (!['detect', 'connect', 'status', 'disconnect'].includes(operation)) {
throw new Error('不支持的 Godot 连接操作');
}
if (operation === 'disconnect' && (executing || uncertain))
throw new Error('Godot 执行尚未完成或结果待核对,不能卸载连接桥');
delete params.operation;
const epoch = projectEpoch;
const result = await request('host.rpc', {
method: `editor.${operation}`,
params,
});
if (epoch !== projectEpoch)
throw new Error('Godot 连接期间项目已切换,回执不属于当前项目');
if (
operation === 'disconnect' &&
(result?.connected !== false ||
Object.hasOwn(result, 'accepted') ||
result?.error ||
result?.status === 'needs-reconciliation')
) {
uncertain = true;
throw new Error('Godot 原生扩展尚未确认卸载,连接状态待核对');
}
return result;
}
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 === GODOT_EXECUTE_COMMAND_ID
? execute
: value.method === GODOT_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 === GODOT_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() {
const epoch = projectEpoch;
const result = await request('host.events.subscribe', {
type: 'project.changed',
});
if (epoch === projectEpoch) activeProjectPath = result?.projectPath ?? null;
await request('host.registerCommand', {
id: GODOT_EXECUTE_COMMAND_ID,
title: '执行 Godot GDScript',
description: '在当前 Godot 项目的编辑器主线程执行 GDScript',
});
await request('host.registerCapability', {
id: GODOT_CONNECTION_CAPABILITY_ID,
description: '当前 Godot 项目的编辑器连接与状态',
});
}
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 startGodotEditorStdioPlugin({
stdin = process.stdin,
stdout = process.stdout,
} = {}) {
const plugin = createGodotEditorPlugin({
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
) {
startGodotEditorStdioPlugin();
}