5388 lines
191 KiB
JavaScript
Executable File
5388 lines
191 KiB
JavaScript
Executable File
"use strict";
|
|
var __create = Object.create;
|
|
var __defProp = Object.defineProperty;
|
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
var __getProtoOf = Object.getPrototypeOf;
|
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
var __export = (target, all) => {
|
|
for (var name in all)
|
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
};
|
|
var __copyProps = (to, from, except, desc) => {
|
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
for (let key of __getOwnPropNames(from))
|
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
}
|
|
return to;
|
|
};
|
|
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
// If the importer is in node compatibility mode or this is not an ESM
|
|
// file that has been converted to a CommonJS file using a Babel-
|
|
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
mod
|
|
));
|
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
|
|
// packages/cocos-ext/src/main.ts
|
|
var main_exports = {};
|
|
__export(main_exports, {
|
|
load: () => load,
|
|
methods: () => methods,
|
|
unload: () => unload
|
|
});
|
|
module.exports = __toCommonJS(main_exports);
|
|
var crypto2 = __toESM(require("crypto"));
|
|
var fs3 = __toESM(require("fs"));
|
|
var path3 = __toESM(require("path"));
|
|
var url = __toESM(require("url"));
|
|
var import_child_process = require("child_process");
|
|
var import_ws = require("ws");
|
|
|
|
// packages/cocos-ext/src/client-config.ts
|
|
var crypto = __toESM(require("crypto"));
|
|
var fs = __toESM(require("fs"));
|
|
var os = __toESM(require("os"));
|
|
var path = __toESM(require("path"));
|
|
function resolveAppSupportPath(platform, homeDir, appDataDir, ...segments) {
|
|
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
|
if (platform === "darwin") {
|
|
return pathApi.join(homeDir, "Library", "Application Support", ...segments);
|
|
}
|
|
if (platform === "win32") {
|
|
return pathApi.join(appDataDir ?? pathApi.join(homeDir, "AppData", "Roaming"), ...segments);
|
|
}
|
|
return pathApi.join(homeDir, ".config", ...segments);
|
|
}
|
|
function firstExistingOrDefault(candidates) {
|
|
return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
|
|
}
|
|
function getClientSpecs(options = {}) {
|
|
const platform = options.platform ?? process.platform;
|
|
const homeDir = options.homeDir ?? os.homedir();
|
|
const appDataDir = options.appDataDir ?? process.env.APPDATA;
|
|
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
|
const appSupport = (...segments) => resolveAppSupportPath(platform, homeDir, appDataDir, ...segments);
|
|
const extensionStorageCandidates = (extensionId, fileName = "mcp_settings.json") => ["Code", "Cursor", "Trae", "Windsurf"].map(
|
|
(host) => appSupport(host, "User", "globalStorage", extensionId, "settings", fileName)
|
|
);
|
|
return [
|
|
{
|
|
clientType: "cursor",
|
|
name: "Cursor",
|
|
configPath: pathApi.join(homeDir, ".cursor", "mcp.json"),
|
|
mode: "json"
|
|
},
|
|
{
|
|
clientType: "windsurf",
|
|
name: "Windsurf",
|
|
configPath: pathApi.join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
mode: "json"
|
|
},
|
|
{
|
|
clientType: "trae-cn",
|
|
name: "Trae CN",
|
|
configPath: appSupport("Trae CN", "User", "mcp.json"),
|
|
mode: "json"
|
|
},
|
|
{
|
|
clientType: "trae-international",
|
|
name: "Trae International",
|
|
configPath: appSupport("Trae", "User", "mcp.json"),
|
|
mode: "json"
|
|
},
|
|
{
|
|
clientType: "zoo-code",
|
|
name: "Zoo Code",
|
|
configPath: firstExistingOrDefault([
|
|
appSupport(
|
|
"Trae",
|
|
"User",
|
|
"globalStorage",
|
|
"zoocodeorganization.zoo-code",
|
|
"settings",
|
|
"mcp_settings.json"
|
|
),
|
|
...extensionStorageCandidates("zoocodeorganization.zoo-code")
|
|
]),
|
|
mode: "json"
|
|
},
|
|
{
|
|
clientType: "cline",
|
|
name: "Cline",
|
|
configPath: firstExistingOrDefault(
|
|
extensionStorageCandidates("saoudrizwan.claude-dev", "cline_mcp_settings.json")
|
|
),
|
|
mode: "json"
|
|
},
|
|
{
|
|
clientType: "roo-code",
|
|
name: "Roo Code",
|
|
configPath: firstExistingOrDefault(extensionStorageCandidates("rooveterinaryinc.roo-cline")),
|
|
mode: "json"
|
|
},
|
|
{
|
|
clientType: "codex-cli",
|
|
name: "Codex CLI",
|
|
configPath: pathApi.join(homeDir, ".codex", "config.toml"),
|
|
mode: "toml"
|
|
}
|
|
];
|
|
}
|
|
function ensureParentDir(filePath) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
}
|
|
function writeFileAtomicWithBackup(filePath, content) {
|
|
ensureParentDir(filePath);
|
|
const existed = fs.existsSync(filePath);
|
|
const backupPath = `${filePath}.cocos-mcp.bak`;
|
|
const tempPath = `${filePath}.cocos-mcp.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
if (existed) {
|
|
fs.copyFileSync(filePath, backupPath);
|
|
}
|
|
try {
|
|
fs.writeFileSync(tempPath, content, "utf-8");
|
|
const temporaryContent = fs.readFileSync(tempPath, "utf-8");
|
|
if (temporaryContent !== content) {
|
|
throw new Error("Temporary config verification failed.");
|
|
}
|
|
fs.renameSync(tempPath, filePath);
|
|
return existed ? { backupPath } : {};
|
|
} catch (error) {
|
|
try {
|
|
fs.rmSync(tempPath, { force: true });
|
|
} catch {
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
function escapeRegex(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
function escapeTomlBasicString(value) {
|
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r").replace(/\n/g, "\\n");
|
|
}
|
|
function readJsonObject(filePath, strict) {
|
|
if (!fs.existsSync(filePath)) {
|
|
return {};
|
|
}
|
|
try {
|
|
const raw = fs.readFileSync(filePath, "utf-8").trim();
|
|
if (!raw) {
|
|
return {};
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
throw new Error(`Config root must be a JSON object: ${filePath}`);
|
|
}
|
|
return parsed;
|
|
} catch (error) {
|
|
if (strict) {
|
|
throw error;
|
|
}
|
|
return {};
|
|
}
|
|
}
|
|
function writeJsonConfig(filePath, serverName, bridge) {
|
|
try {
|
|
const root = readJsonObject(filePath, true);
|
|
const rawServers = root.mcpServers;
|
|
if (rawServers !== void 0 && (typeof rawServers !== "object" || rawServers === null || Array.isArray(rawServers))) {
|
|
throw new Error(`mcpServers must be a JSON object: ${filePath}`);
|
|
}
|
|
const mcpServers = typeof rawServers === "object" && rawServers !== null && !Array.isArray(rawServers) ? rawServers : {};
|
|
mcpServers[serverName] = { command: bridge.command, args: bridge.args };
|
|
root.mcpServers = mcpServers;
|
|
const content = `${JSON.stringify(root, null, 2)}
|
|
`;
|
|
JSON.parse(content);
|
|
const result = writeFileAtomicWithBackup(filePath, content);
|
|
if (!hasServerInJson(filePath, serverName, bridge)) {
|
|
restoreConfigAfterFailedVerification(filePath, result.backupPath);
|
|
throw new Error(`Written JSON config did not pass verification: ${filePath}`);
|
|
}
|
|
return {
|
|
ok: true,
|
|
message: `updated ${filePath}${result.backupPath ? ` (backup: ${result.backupPath})` : ""}`
|
|
};
|
|
} catch (error) {
|
|
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
function codexSection(serverName, bridge) {
|
|
const key = serverName.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
const args = bridge.args.map((arg) => `"${escapeTomlBasicString(arg)}"`).join(", ");
|
|
return {
|
|
key,
|
|
block: `[mcp_servers.${key}]
|
|
command = "${escapeTomlBasicString(bridge.command)}"
|
|
args = [${args}]
|
|
`
|
|
};
|
|
}
|
|
function restoreConfigAfterFailedVerification(filePath, backupPath) {
|
|
if (backupPath && fs.existsSync(backupPath)) {
|
|
fs.copyFileSync(backupPath, filePath);
|
|
return;
|
|
}
|
|
fs.rmSync(filePath, { force: true });
|
|
}
|
|
function hasServerInCodexTomlText(raw, serverName, bridge) {
|
|
const { key } = codexSection(serverName, bridge);
|
|
const section = new RegExp(
|
|
`\\[mcp_servers\\.${escapeRegex(key)}\\][\\s\\S]*?(?=\\n\\[[^\\]]+\\]|$)`
|
|
).exec(raw)?.[0];
|
|
if (!section) {
|
|
return false;
|
|
}
|
|
const commandLine = `command = "${escapeTomlBasicString(bridge.command)}"`;
|
|
const expectedArgs = bridge.args.map((arg) => `"${escapeTomlBasicString(arg)}"`).join(", ");
|
|
const argsLine = `args = [${expectedArgs}]`;
|
|
const normalizedLines = section.split(/\r?\n/).map((line) => line.trim());
|
|
return normalizedLines.includes(commandLine) && normalizedLines.includes(argsLine);
|
|
}
|
|
function writeCodexToml(filePath, serverName, bridge) {
|
|
try {
|
|
const { key, block } = codexSection(serverName, bridge);
|
|
let raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
|
|
const sectionRe = new RegExp(
|
|
`\\[mcp_servers\\.${escapeRegex(key)}\\][\\s\\S]*?(?=\\n\\[[^\\]]+\\]|$)`
|
|
);
|
|
raw = sectionRe.test(raw) ? raw.replace(sectionRe, block.trimEnd()) : `${raw.trimEnd()}
|
|
|
|
${block}`;
|
|
const content = `${raw.trimStart().trimEnd()}
|
|
`;
|
|
if (!hasServerInCodexTomlText(content, serverName, bridge)) {
|
|
throw new Error(`Generated Codex config did not pass verification: ${filePath}`);
|
|
}
|
|
const result = writeFileAtomicWithBackup(filePath, content);
|
|
if (!hasServerInCodexToml(filePath, serverName, bridge)) {
|
|
restoreConfigAfterFailedVerification(filePath, result.backupPath);
|
|
throw new Error(`Written Codex config did not pass verification: ${filePath}`);
|
|
}
|
|
return {
|
|
ok: true,
|
|
message: `updated ${filePath}${result.backupPath ? ` (backup: ${result.backupPath})` : ""}`
|
|
};
|
|
} catch (error) {
|
|
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
function hasServerInJson(filePath, serverName, bridge) {
|
|
const root = readJsonObject(filePath, false);
|
|
const servers = root.mcpServers;
|
|
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
|
|
return false;
|
|
}
|
|
const entry = servers[serverName];
|
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
return false;
|
|
}
|
|
const record = entry;
|
|
return record.command === bridge.command && Array.isArray(record.args) && record.args.length === bridge.args.length && record.args.every((arg, index) => arg === bridge.args[index]);
|
|
}
|
|
function hasServerInCodexToml(filePath, serverName, bridge) {
|
|
if (!fs.existsSync(filePath)) {
|
|
return false;
|
|
}
|
|
try {
|
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
return hasServerInCodexTomlText(raw, serverName, bridge);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// packages/cocos-ext/src/ui-assets.ts
|
|
var fs2 = __toESM(require("fs"));
|
|
var path2 = __toESM(require("path"));
|
|
var zlib = __toESM(require("zlib"));
|
|
var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
function crc32(buffer) {
|
|
let crc = 4294967295;
|
|
for (const byte of buffer) {
|
|
crc ^= byte;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
crc = crc >>> 1 ^ 3988292384 & -(crc & 1);
|
|
}
|
|
}
|
|
return (crc ^ 4294967295) >>> 0;
|
|
}
|
|
function pngChunk(type, data) {
|
|
const typeBuffer = Buffer.from(type, "ascii");
|
|
const length = Buffer.alloc(4);
|
|
length.writeUInt32BE(data.length, 0);
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
|
|
return Buffer.concat([length, typeBuffer, data, crc]);
|
|
}
|
|
function pixelVisible(shape, x, y, size) {
|
|
if (shape === "rectangle") {
|
|
return true;
|
|
}
|
|
if (shape === "circle") {
|
|
const center = (size - 1) / 2;
|
|
const radius2 = center - 1;
|
|
const dx2 = x - center;
|
|
const dy2 = y - center;
|
|
return dx2 * dx2 + dy2 * dy2 <= radius2 * radius2;
|
|
}
|
|
const radius = Math.max(4, Math.floor(size * 0.18));
|
|
const left = radius;
|
|
const right = size - radius - 1;
|
|
const top = radius;
|
|
const bottom = size - radius - 1;
|
|
if (x >= left && x <= right || y >= top && y <= bottom) {
|
|
return true;
|
|
}
|
|
const cornerX = x < left ? left : right;
|
|
const cornerY = y < top ? top : bottom;
|
|
const dx = x - cornerX;
|
|
const dy = y - cornerY;
|
|
return dx * dx + dy * dy <= radius * radius;
|
|
}
|
|
function createManagedShapePng(shape, size = 128) {
|
|
if (!Number.isInteger(size) || size < 16 || size > 512) {
|
|
throw new Error("Managed shape PNG size must be an integer from 16 to 512.");
|
|
}
|
|
const stride = size * 4 + 1;
|
|
const raw = Buffer.alloc(stride * size);
|
|
for (let y = 0; y < size; y += 1) {
|
|
const rowOffset = y * stride;
|
|
raw[rowOffset] = 0;
|
|
for (let x = 0; x < size; x += 1) {
|
|
const offset = rowOffset + 1 + x * 4;
|
|
raw[offset] = 255;
|
|
raw[offset + 1] = 255;
|
|
raw[offset + 2] = 255;
|
|
raw[offset + 3] = pixelVisible(shape, x, y, size) ? 255 : 0;
|
|
}
|
|
}
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(size, 0);
|
|
ihdr.writeUInt32BE(size, 4);
|
|
ihdr[8] = 8;
|
|
ihdr[9] = 6;
|
|
ihdr[10] = 0;
|
|
ihdr[11] = 0;
|
|
ihdr[12] = 0;
|
|
return Buffer.concat([
|
|
PNG_SIGNATURE,
|
|
pngChunk("IHDR", ihdr),
|
|
pngChunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
|
|
pngChunk("IEND", Buffer.alloc(0))
|
|
]);
|
|
}
|
|
function ensureManagedShapeAsset(projectRoot, shape) {
|
|
const relativePath = `assets/cocos-mcp-generated/shapes/${shape}.png`;
|
|
const absolutePath = path2.join(projectRoot, ...relativePath.split("/"));
|
|
const expected = createManagedShapePng(shape);
|
|
fs2.mkdirSync(path2.dirname(absolutePath), { recursive: true });
|
|
if (fs2.existsSync(absolutePath)) {
|
|
const existing = fs2.readFileSync(absolutePath);
|
|
if (existing.equals(expected)) {
|
|
return { relativePath, absolutePath, created: false };
|
|
}
|
|
}
|
|
const temporary = `${absolutePath}.cocos-mcp.tmp`;
|
|
fs2.writeFileSync(temporary, expected);
|
|
fs2.renameSync(temporary, absolutePath);
|
|
return { relativePath, absolutePath, created: true };
|
|
}
|
|
function prepareManagedShapeSpriteFrameMeta(rawMeta) {
|
|
let parsed = rawMeta;
|
|
if (typeof rawMeta === "string") {
|
|
try {
|
|
parsed = JSON.parse(rawMeta);
|
|
} catch {
|
|
throw new Error("Managed shape asset meta is not valid JSON.");
|
|
}
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("Managed shape asset meta is missing or invalid.");
|
|
}
|
|
const meta = JSON.parse(JSON.stringify(parsed));
|
|
const existingUserData = meta.userData;
|
|
const userData = existingUserData && typeof existingUserData === "object" && !Array.isArray(existingUserData) ? existingUserData : {};
|
|
const changed = userData.type !== "sprite-frame";
|
|
userData.type = "sprite-frame";
|
|
meta.userData = userData;
|
|
return { meta, changed };
|
|
}
|
|
|
|
// packages/cocos-ext/src/main.ts
|
|
var SUPPORTED_PROTOCOL_VERSION = 1;
|
|
var DEFAULT_LOG_RELATIVE = "temp/logs/project.log";
|
|
var wss = null;
|
|
var serverToken = "";
|
|
var discoveryPath = "";
|
|
var activeWsClients = /* @__PURE__ */ new Set();
|
|
var wsRequestQueue = Promise.resolve();
|
|
var serverStopping = false;
|
|
var startServerPromise = null;
|
|
var pendingWss = null;
|
|
var cancelPendingStart = null;
|
|
var stopServerPromise = null;
|
|
var panelLogs = [];
|
|
var PANEL_LOG_MAX = 200;
|
|
function panelLog(level, message) {
|
|
panelLogs.push({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, message });
|
|
if (panelLogs.length > PANEL_LOG_MAX) {
|
|
panelLogs.splice(0, panelLogs.length - PANEL_LOG_MAX);
|
|
}
|
|
const prefix = "[cocos-mcp panel]";
|
|
if (level === "error") {
|
|
console.error(prefix, message);
|
|
} else if (level === "warn") {
|
|
console.warn(prefix, message);
|
|
} else {
|
|
console.log(prefix, message);
|
|
}
|
|
}
|
|
var MCP_UNDO_MAX = 2048;
|
|
var mcpUndoStack = [];
|
|
var mcpUndoSceneUuid = "";
|
|
async function clearMcpUndoStack() {
|
|
mcpUndoStack.length = 0;
|
|
mcpUndoSceneUuid = "";
|
|
try {
|
|
await executeSceneMethod("discardDetachedNodesForMcpUndo", []);
|
|
} catch {
|
|
}
|
|
}
|
|
var VEC_EPS = 1e-5;
|
|
function cloneVec3(v) {
|
|
return { x: v.x, y: v.y, z: v.z };
|
|
}
|
|
function vec3NearlyEqual(a, b) {
|
|
return Math.abs(a.x - b.x) < VEC_EPS && Math.abs(a.y - b.y) < VEC_EPS && Math.abs(a.z - b.z) < VEC_EPS;
|
|
}
|
|
function readVec3FromPropertyDump(dump) {
|
|
const v = dump.value;
|
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
const o = v;
|
|
if (typeof o.x === "number" && typeof o.y === "number" && typeof o.z === "number") {
|
|
return { x: o.x, y: o.y, z: o.z };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function applyVec3ToPropertyDump(dump, vec) {
|
|
const v = dump.value;
|
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
const o = v;
|
|
if (typeof o.x === "number" && typeof o.y === "number" && typeof o.z === "number") {
|
|
o.x = vec.x;
|
|
o.y = vec.y;
|
|
o.z = vec.z;
|
|
return;
|
|
}
|
|
}
|
|
dump.value = { x: vec.x, y: vec.y, z: vec.z };
|
|
}
|
|
function getDiscoveryPath() {
|
|
return path3.join(Editor.Project.path, "temp", ".cocos-mcp.json");
|
|
}
|
|
function writeDiscovery(port, token) {
|
|
const dir = path3.join(Editor.Project.path, "temp");
|
|
if (!fs3.existsSync(dir)) {
|
|
fs3.mkdirSync(dir, { recursive: true });
|
|
}
|
|
const payload = { port, token };
|
|
fs3.writeFileSync(getDiscoveryPath(), JSON.stringify(payload), "utf-8");
|
|
discoveryPath = getDiscoveryPath();
|
|
}
|
|
function removeDiscovery() {
|
|
const candidates = /* @__PURE__ */ new Set();
|
|
if (discoveryPath) {
|
|
candidates.add(discoveryPath);
|
|
}
|
|
try {
|
|
candidates.add(getDiscoveryPath());
|
|
} catch {
|
|
}
|
|
for (const candidate of candidates) {
|
|
try {
|
|
if (candidate && fs3.existsSync(candidate)) {
|
|
fs3.unlinkSync(candidate);
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
discoveryPath = "";
|
|
}
|
|
function randomToken() {
|
|
return crypto2.randomBytes(32).toString("hex");
|
|
}
|
|
var NODE_PROBE_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
var nodeProbeCache = null;
|
|
function detectNodeAbsolutePath() {
|
|
const now = Date.now();
|
|
if (nodeProbeCache && now < nodeProbeCache.until) {
|
|
return nodeProbeCache.result;
|
|
}
|
|
const tryValidate = (raw) => {
|
|
const line = raw.split(/\r?\n/).map((l) => l.trim())[0];
|
|
if (!line) {
|
|
return null;
|
|
}
|
|
const candidate = line.replace(/^['"]|['"]$/g, "");
|
|
if (!candidate) {
|
|
return null;
|
|
}
|
|
try {
|
|
if (!fs3.existsSync(candidate)) {
|
|
return null;
|
|
}
|
|
const st = fs3.statSync(candidate);
|
|
if (!st.isFile()) {
|
|
return null;
|
|
}
|
|
if (process.platform !== "win32") {
|
|
try {
|
|
fs3.accessSync(candidate, fs3.constants.X_OK);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return candidate;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const runProbe = (file, args, timeoutMs) => {
|
|
const r = (0, import_child_process.spawnSync)(file, args, {
|
|
encoding: "utf-8",
|
|
timeout: timeoutMs,
|
|
maxBuffer: 512 * 1024,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true
|
|
});
|
|
if (r.error || r.signal) {
|
|
return null;
|
|
}
|
|
const out = `${r.stdout ?? ""}`.trim();
|
|
if (!out) {
|
|
return null;
|
|
}
|
|
if (process.platform === "win32") {
|
|
const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
|
return first ? tryValidate(first) : null;
|
|
}
|
|
return tryValidate(out);
|
|
};
|
|
let resolved = null;
|
|
for (const environmentCandidate of [process.env.NODE_BINARY, process.env.NODE]) {
|
|
if (!environmentCandidate) {
|
|
continue;
|
|
}
|
|
resolved = tryValidate(environmentCandidate);
|
|
if (resolved) {
|
|
break;
|
|
}
|
|
}
|
|
if (!resolved && process.platform === "win32") {
|
|
const whereExe = path3.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "where.exe");
|
|
if (fs3.existsSync(whereExe)) {
|
|
resolved = runProbe(whereExe, ["node"], 6e3);
|
|
}
|
|
if (!resolved) {
|
|
resolved = runProbe("cmd.exe", ["/d", "/s", "/c", "where node"], 6e3);
|
|
}
|
|
if (!resolved) {
|
|
const windowsCandidates = [
|
|
process.env.NVM_SYMLINK ? path3.join(process.env.NVM_SYMLINK, "node.exe") : "",
|
|
process.env.VOLTA_HOME ? path3.join(process.env.VOLTA_HOME, "bin", "node.exe") : "",
|
|
process.env.ProgramFiles ? path3.join(process.env.ProgramFiles, "nodejs", "node.exe") : "",
|
|
process.env["ProgramFiles(x86)"] ? path3.join(process.env["ProgramFiles(x86)"], "nodejs", "node.exe") : "",
|
|
process.env.LOCALAPPDATA ? path3.join(process.env.LOCALAPPDATA, "Programs", "nodejs", "node.exe") : ""
|
|
];
|
|
for (const candidate of windowsCandidates) {
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
resolved = tryValidate(candidate);
|
|
if (resolved) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else if (!resolved) {
|
|
const timeoutMs = 6e3;
|
|
const shells = [];
|
|
const userShell = process.env.SHELL;
|
|
if (userShell && fs3.existsSync(userShell)) {
|
|
shells.push({ file: userShell, args: ["-lc", "command -v node"] });
|
|
}
|
|
shells.push(
|
|
{ file: "/bin/zsh", args: ["-lc", "command -v node"] },
|
|
{ file: "/bin/bash", args: ["-lc", "command -v node"] }
|
|
);
|
|
for (const s of shells) {
|
|
if (!fs3.existsSync(s.file)) {
|
|
continue;
|
|
}
|
|
resolved = runProbe(s.file, s.args, timeoutMs);
|
|
if (resolved) {
|
|
break;
|
|
}
|
|
}
|
|
if (!resolved) {
|
|
for (const s of shells) {
|
|
if (!fs3.existsSync(s.file)) {
|
|
continue;
|
|
}
|
|
resolved = runProbe(s.file, ["-ilc", "command -v node"], Math.min(timeoutMs, 4e3));
|
|
if (resolved) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!resolved) {
|
|
const fallbacks = ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
|
|
for (const p of fallbacks) {
|
|
const v = tryValidate(p);
|
|
if (v) {
|
|
resolved = v;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
nodeProbeCache = { result: resolved, until: now + NODE_PROBE_CACHE_TTL_MS };
|
|
if (!resolved) {
|
|
panelLog(
|
|
"warn",
|
|
"Could not resolve an absolute Node executable. Auto Config will stop instead of writing a fragile PATH-dependent entry."
|
|
);
|
|
}
|
|
return resolved;
|
|
}
|
|
function getBundledBridgeCliPath() {
|
|
const extRoot = path3.resolve(__dirname, "..");
|
|
return path3.join(extRoot, "dist", "bridge-cli.js");
|
|
}
|
|
function getBridgeLaunchConfig() {
|
|
const projectRoot = Editor.Project.path;
|
|
const bridgeCliPath = getBundledBridgeCliPath();
|
|
if (!fs3.existsSync(bridgeCliPath)) {
|
|
return {
|
|
ok: false,
|
|
message: `bundled bridge cli not found: ${bridgeCliPath}. Please reinstall the extension package with dist/bridge-cli.js included.`
|
|
};
|
|
}
|
|
const nodeBin = detectNodeAbsolutePath();
|
|
if (!nodeBin) {
|
|
return {
|
|
ok: false,
|
|
message: "Node.js was not found as an absolute executable path. Install Node.js 18-24, restart Creator, and run Auto Config again."
|
|
};
|
|
}
|
|
if (!fs3.existsSync(nodeBin)) {
|
|
return {
|
|
ok: false,
|
|
message: `Node executable not found: ${nodeBin}. Install Node.js 18-24 and run Auto Config again.`
|
|
};
|
|
}
|
|
const versionProbe = (0, import_child_process.spawnSync)(nodeBin, ["--version"], {
|
|
encoding: "utf-8",
|
|
timeout: 5e3,
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
const versionText = `${versionProbe.stdout ?? ""}`.trim();
|
|
const major = Number(/^v?(\d+)/.exec(versionText)?.[1]);
|
|
if (versionProbe.error || versionProbe.status !== 0 || !Number.isInteger(major)) {
|
|
return {
|
|
ok: false,
|
|
message: `Node executable could not be verified: ${nodeBin}. ` + (versionProbe.error?.message ?? `${versionProbe.stderr ?? ""}`.trim() ?? "unknown error")
|
|
};
|
|
}
|
|
if (major < 18 || major > 24) {
|
|
return {
|
|
ok: false,
|
|
message: `Unsupported Node.js ${versionText}; install Node.js 18-24 and run Auto Config again.`
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
command: nodeBin,
|
|
args: [bridgeCliPath, "--project", path3.resolve(projectRoot)],
|
|
nodeBin,
|
|
bridgeCliPath,
|
|
projectRoot: path3.resolve(projectRoot)
|
|
}
|
|
};
|
|
}
|
|
function openFileInSystem(filePath) {
|
|
try {
|
|
let cmd = "";
|
|
if (process.platform === "darwin") {
|
|
try {
|
|
(0, import_child_process.execSync)(`open -a "Cursor" "${filePath}"`, { stdio: "ignore" });
|
|
return { ok: true, message: `opened in Cursor: ${filePath}` };
|
|
} catch {
|
|
cmd = `open "${filePath}"`;
|
|
}
|
|
} else if (process.platform === "win32") {
|
|
cmd = `start "" "${filePath}"`;
|
|
} else {
|
|
cmd = `xdg-open "${filePath}"`;
|
|
}
|
|
(0, import_child_process.exec)(cmd, (err) => {
|
|
if (err) {
|
|
panelLog("warn", `open config failed: ${filePath} :: ${err.message}`);
|
|
}
|
|
});
|
|
return { ok: true, message: `opened ${filePath}` };
|
|
} catch (e) {
|
|
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}
|
|
async function executeSceneMethod(method, args) {
|
|
return Editor.Message.request(
|
|
"scene",
|
|
"execute-scene-script",
|
|
{
|
|
name: "cocos-mcp",
|
|
script: "scene",
|
|
method,
|
|
args
|
|
}
|
|
);
|
|
}
|
|
async function handleScenePing(id) {
|
|
try {
|
|
const result = await executeSceneMethod("ping", []);
|
|
return { op: "scene_pong", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
function isSetNodeActivePrepareOk(v) {
|
|
return typeof v === "object" && v !== null && v.ok === true && typeof v.uuid === "string";
|
|
}
|
|
function isSetNodeActivePrepareErr(v) {
|
|
return typeof v === "object" && v !== null && v.ok === false && typeof v.message === "string";
|
|
}
|
|
function isPrepareSetComponentPropertyOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.uuid === "string" && typeof o.path === "string" && typeof o.componentType === "string";
|
|
}
|
|
function isPrepareSetNodeNameOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.uuid === "string" && typeof o.name === "string";
|
|
}
|
|
function isPrepareCreateNodeOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.parentUuid === "string" && typeof o.name === "string";
|
|
}
|
|
function isRegisterNidForNewNodeOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.nid === "number" && Number.isInteger(o.nid);
|
|
}
|
|
function isPrepareDeleteNodeOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.uuid === "string";
|
|
}
|
|
function isPrepareInstantiatePrefabOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.parentUuid === "string";
|
|
}
|
|
function isPrepareAddComponentOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.uuid === "string" && typeof o.componentType === "string";
|
|
}
|
|
function isPrepareRemoveComponentOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.uuid === "string" && (o.componentUuid === void 0 || typeof o.componentUuid === "string" && o.componentUuid.trim().length > 0) && typeof o.componentIndex === "number" && Number.isInteger(o.componentIndex) && o.componentIndex >= 0 && typeof o.componentType === "string";
|
|
}
|
|
function isPrepareDuplicateNodeOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.uuid === "string" && typeof o.parentUuid === "string";
|
|
}
|
|
function isPrepareSetNodeSiblingIndexOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.uuid === "string" && typeof o.parentUuid === "string" && typeof o.index === "number" && Number.isInteger(o.index) && typeof o.previousIndex === "number" && Number.isInteger(o.previousIndex) && typeof o.noOp === "boolean";
|
|
}
|
|
function isPrepareReparentNodeOk(v) {
|
|
if (typeof v !== "object" || v === null) {
|
|
return false;
|
|
}
|
|
const o = v;
|
|
return o.ok === true && typeof o.childUuid === "string" && typeof o.newParentUuid === "string" && typeof o.previousParentUuid === "string" && typeof o.keepWorldTransform === "boolean" && typeof o.noOp === "boolean";
|
|
}
|
|
function inodeRecord(v) {
|
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
}
|
|
function getDumpAtInodePath(inode, dotPath) {
|
|
const segments = dotPath.split(".");
|
|
let cur = inode;
|
|
for (const seg of segments) {
|
|
if (cur === null || cur === void 0) {
|
|
return null;
|
|
}
|
|
if (/^\d+$/.test(seg)) {
|
|
const idx = Number(seg);
|
|
if (!Array.isArray(cur) || idx < 0 || idx >= cur.length) {
|
|
return null;
|
|
}
|
|
cur = cur[idx];
|
|
} else {
|
|
if (!inodeRecord(cur)) {
|
|
return null;
|
|
}
|
|
cur = cur[seg];
|
|
}
|
|
}
|
|
return inodeRecord(cur) ? cur : null;
|
|
}
|
|
function resolveComponentPropertyPathFallback(inode, requestedPath, componentType, property) {
|
|
const prop = property.trim();
|
|
const propCandidates = [prop, `_${prop}`];
|
|
const compsRaw = inode.__comps__;
|
|
if (!Array.isArray(compsRaw) || prop.length === 0) {
|
|
return null;
|
|
}
|
|
const normType = normalizeComponentTypeName(componentType);
|
|
const typedMatchesByProp = /* @__PURE__ */ new Map();
|
|
const propOnlyMatchesByProp = /* @__PURE__ */ new Map();
|
|
for (const p of propCandidates) {
|
|
typedMatchesByProp.set(p, []);
|
|
propOnlyMatchesByProp.set(p, []);
|
|
}
|
|
for (let i = 0; i < compsRaw.length; i += 1) {
|
|
const comp = compsRaw[i];
|
|
if (!inodeRecord(comp)) {
|
|
continue;
|
|
}
|
|
for (const p of propCandidates) {
|
|
if (!(p in comp)) {
|
|
continue;
|
|
}
|
|
propOnlyMatchesByProp.get(p)?.push(i);
|
|
const dumpType = readComponentTypeFromDump(comp);
|
|
if (dumpType && normalizeComponentTypeName(dumpType) === normType) {
|
|
typedMatchesByProp.get(p)?.push(i);
|
|
}
|
|
}
|
|
}
|
|
for (const p of propCandidates) {
|
|
const typed = typedMatchesByProp.get(p) ?? [];
|
|
const propOnly = propOnlyMatchesByProp.get(p) ?? [];
|
|
const candidates = typed.length > 0 ? typed : propOnly;
|
|
if (candidates.length === 0) {
|
|
continue;
|
|
}
|
|
const m = /^__comps__\.(\d+)\./.exec(requestedPath);
|
|
const requestedIdx = m ? Number(m[1]) : -1;
|
|
if (requestedIdx >= 0 && candidates.includes(requestedIdx)) {
|
|
return `__comps__.${requestedIdx}.${p}`;
|
|
}
|
|
const picked = candidates[candidates.length - 1];
|
|
return `__comps__.${picked}.${p}`;
|
|
}
|
|
return null;
|
|
}
|
|
function readComponentDumpCount(inode) {
|
|
const comps = inode.__comps__;
|
|
return Array.isArray(comps) ? comps.length : 0;
|
|
}
|
|
function readComponentDumps(inode) {
|
|
const comps = inode.__comps__;
|
|
if (!Array.isArray(comps)) {
|
|
return [];
|
|
}
|
|
return comps.filter((c) => inodeRecord(c));
|
|
}
|
|
function normalizeComponentTypeName(raw) {
|
|
return raw.trim().replace(/^cc\./, "").toLowerCase();
|
|
}
|
|
function readComponentTypeFromDump(dump) {
|
|
const typeCandidates = [dump.__type__, dump.type, dump._type];
|
|
for (const c of typeCandidates) {
|
|
if (typeof c === "string" && c.trim().length > 0) {
|
|
return c.trim();
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function readComponentIdentityFromDump(dump) {
|
|
const idCandidates = [dump.__id__, dump._id, dump.id, dump.uuid, dump.__uuid__];
|
|
for (const c of idCandidates) {
|
|
if (typeof c === "string" && c.trim().length > 0) {
|
|
return c.trim();
|
|
}
|
|
if (typeof c === "number" && Number.isFinite(c)) {
|
|
return String(c);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function readComponentUuidFromDump(dump) {
|
|
const readStringValue = (candidate) => {
|
|
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
return candidate.trim();
|
|
}
|
|
if (inodeRecord(candidate)) {
|
|
const nested = candidate.value;
|
|
if (typeof nested === "string" && nested.trim().length > 0) {
|
|
return nested.trim();
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
const value = inodeRecord(dump.value) ? dump.value : null;
|
|
for (const candidate of [value?.uuid, value?.__uuid__, dump.componentUuid, dump.uuid, dump.__uuid__]) {
|
|
const uuid = readStringValue(candidate);
|
|
if (uuid) {
|
|
return uuid;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function pickUndoAddComponentIndex(comps, entry) {
|
|
if (comps.length === 0) {
|
|
return 0;
|
|
}
|
|
const safeIndex = Math.max(0, Math.min(entry.componentIndex, comps.length - 1));
|
|
const typeAtOriginal = readComponentTypeFromDump(comps[safeIndex] ?? {});
|
|
if (typeAtOriginal && normalizeComponentTypeName(typeAtOriginal) === normalizeComponentTypeName(entry.componentType)) {
|
|
return safeIndex;
|
|
}
|
|
const targetId = readComponentIdentityFromDump(entry.addedComponentDump);
|
|
if (targetId) {
|
|
const byId = comps.findIndex((c) => readComponentIdentityFromDump(c) === targetId);
|
|
if (byId >= 0) {
|
|
return byId;
|
|
}
|
|
}
|
|
const sameTypeIndexes = [];
|
|
for (let i = 0; i < comps.length; i += 1) {
|
|
const t = readComponentTypeFromDump(comps[i]);
|
|
if (t && normalizeComponentTypeName(t) === normalizeComponentTypeName(entry.componentType)) {
|
|
sameTypeIndexes.push(i);
|
|
}
|
|
}
|
|
if (sameTypeIndexes.length > 0) {
|
|
return sameTypeIndexes[sameTypeIndexes.length - 1];
|
|
}
|
|
const targetSig = safeJsonSignature(entry.addedComponentDump);
|
|
const matched = comps.findIndex((c) => safeJsonSignature(c) === targetSig);
|
|
if (matched >= 0) {
|
|
return matched;
|
|
}
|
|
return safeIndex;
|
|
}
|
|
async function restoreComponentTypeBaselineAfterAdd(entry) {
|
|
const cleanupTypes = [...entry.addedComponentTypes].reverse();
|
|
if (!cleanupTypes.some((type) => normalizeComponentTypeName(type) === normalizeComponentTypeName(entry.componentType))) {
|
|
cleanupTypes.unshift(entry.componentType);
|
|
}
|
|
const processed = /* @__PURE__ */ new Set();
|
|
for (const componentType of cleanupTypes) {
|
|
const normalized = normalizeComponentTypeName(componentType);
|
|
if (!normalized || processed.has(normalized)) {
|
|
continue;
|
|
}
|
|
processed.add(normalized);
|
|
const baseline = entry.beforeComponentTypeCounts[normalized] ?? 0;
|
|
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
const stats = await executeSceneMethod("getComponentStatsByType", [
|
|
entry.uuid,
|
|
componentType
|
|
]);
|
|
if (!inodeRecord(stats) || stats.ok !== true || typeof stats.typeCount !== "number" || !Number.isFinite(stats.typeCount)) {
|
|
throw new Error(`cannot verify component baseline for ${componentType}`);
|
|
}
|
|
if (stats.typeCount <= baseline) {
|
|
break;
|
|
}
|
|
const removed = await executeSceneMethod("removeLastComponentByType", [
|
|
entry.uuid,
|
|
componentType
|
|
]);
|
|
if (!inodeRecord(removed) || removed.ok !== true) {
|
|
throw new Error(`failed to restore component baseline for ${componentType}`);
|
|
}
|
|
const removedAfterWait = await waitRuntimeTypeCountBelow(
|
|
entry.uuid,
|
|
componentType,
|
|
stats.typeCount
|
|
);
|
|
if (removed.removed !== true && !removedAfterWait) {
|
|
throw new Error(`failed to restore component baseline for ${componentType}`);
|
|
}
|
|
}
|
|
const finalStats = await executeSceneMethod("getComponentStatsByType", [
|
|
entry.uuid,
|
|
componentType
|
|
]);
|
|
if (!inodeRecord(finalStats) || finalStats.ok !== true || typeof finalStats.typeCount !== "number" || finalStats.typeCount !== baseline) {
|
|
throw new Error(
|
|
`component baseline mismatch for ${componentType} (expected=${baseline}, actual=${inodeRecord(finalStats) ? String(finalStats.typeCount) : "unknown"})`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
function isLikelyUuidString(v) {
|
|
if (typeof v !== "string") {
|
|
return false;
|
|
}
|
|
const s = v.trim();
|
|
return s.length >= 16 && /^[A-Za-z0-9_-]+$/.test(s);
|
|
}
|
|
function readUuidFromUnknown(v) {
|
|
if (isLikelyUuidString(v)) {
|
|
return v;
|
|
}
|
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
const o = v;
|
|
const candidates = [o.uuid, o.__uuid__, o.value];
|
|
for (const c of candidates) {
|
|
if (isLikelyUuidString(c)) {
|
|
return c;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function collectChildUuidsFromParentInode(inode) {
|
|
const out = /* @__PURE__ */ new Set();
|
|
const keys = ["children", "__children__", "_children", "childrens"];
|
|
for (const k of keys) {
|
|
const raw = inode[k];
|
|
if (!Array.isArray(raw)) {
|
|
continue;
|
|
}
|
|
for (const item of raw) {
|
|
const u = readUuidFromUnknown(item);
|
|
if (u) {
|
|
out.add(u);
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
async function readParentChildUuidSet(parentUuid) {
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", parentUuid);
|
|
if (!inodeRecord(inode)) {
|
|
return /* @__PURE__ */ new Set();
|
|
}
|
|
return collectChildUuidsFromParentInode(inode);
|
|
} catch {
|
|
return /* @__PURE__ */ new Set();
|
|
}
|
|
}
|
|
async function listChildUuidsFromSceneScript(parentUuid) {
|
|
try {
|
|
const raw = await executeSceneMethod("listChildUuidsUnderParent", [parentUuid]);
|
|
if (!inodeRecord(raw) || raw.ok !== true || !Array.isArray(raw.uuids)) {
|
|
return null;
|
|
}
|
|
const uuids = raw.uuids;
|
|
if (!uuids.every((u) => typeof u === "string")) {
|
|
return null;
|
|
}
|
|
return uuids;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
async function getParentChildUuidSetForDuplicate(parentUuid) {
|
|
const list = await listChildUuidsFromSceneScript(parentUuid);
|
|
if (list) {
|
|
return new Set(list);
|
|
}
|
|
panelLog("warn", "listChildUuidsUnderParent unavailable; falling back to query-node inode children");
|
|
return readParentChildUuidSet(parentUuid);
|
|
}
|
|
function safeJsonSignature(v) {
|
|
try {
|
|
return JSON.stringify(v);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
async function getRuntimeComponentTypeCount(uuid, componentType) {
|
|
const s = await getRuntimeComponentStats(uuid, componentType);
|
|
return s?.typeCount ?? null;
|
|
}
|
|
async function getRuntimeComponentStats(uuid, componentType) {
|
|
try {
|
|
const raw = await executeSceneMethod("getComponentStatsByType", [uuid, componentType]);
|
|
if (inodeRecord(raw) && raw.ok === true && typeof raw.totalCount === "number" && Number.isFinite(raw.totalCount) && typeof raw.typeCount === "number" && Number.isFinite(raw.typeCount)) {
|
|
return { totalCount: raw.totalCount, typeCount: raw.typeCount };
|
|
}
|
|
} catch {
|
|
}
|
|
return null;
|
|
}
|
|
async function getRuntimeComponentUuidState(nodeUuid, componentUuid) {
|
|
try {
|
|
const raw = await executeSceneMethod("getComponentUuidState", [
|
|
nodeUuid,
|
|
componentUuid
|
|
]);
|
|
if (inodeRecord(raw) && raw.ok === true && typeof raw.present === "boolean") {
|
|
return {
|
|
present: raw.present,
|
|
...typeof raw.componentIndex === "number" ? { componentIndex: raw.componentIndex } : {},
|
|
...typeof raw.componentType === "string" ? { componentType: raw.componentType } : {}
|
|
};
|
|
}
|
|
} catch {
|
|
}
|
|
return null;
|
|
}
|
|
function sleep(ms) {
|
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
}
|
|
async function waitRuntimeTypeCountBelow(uuid, componentType, before, retries = 3, delayMs = 20) {
|
|
if (before === null) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i <= retries; i += 1) {
|
|
const now = await getRuntimeComponentTypeCount(uuid, componentType);
|
|
if (now !== null && now < before) {
|
|
return true;
|
|
}
|
|
if (i < retries) {
|
|
await sleep(delayMs);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
async function waitRuntimeTypeCountAbove(uuid, componentType, before, retries = 3, delayMs = 20) {
|
|
if (before === null) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i <= retries; i += 1) {
|
|
const now = await getRuntimeComponentTypeCount(uuid, componentType);
|
|
if (now !== null && now > before) {
|
|
return true;
|
|
}
|
|
if (i < retries) {
|
|
await sleep(delayMs);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
async function rollbackCreatedNodeWithVerification(uuid) {
|
|
try {
|
|
await Editor.Message.request("scene", "remove-node", { uuid });
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { ok: false, message: `rollback remove-node failed: ${message}` };
|
|
}
|
|
try {
|
|
await executeSceneMethod("forgetNodeNid", [uuid]);
|
|
} catch {
|
|
}
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", uuid);
|
|
if (inodeRecord(inode)) {
|
|
return { ok: false, message: "rollback verification failed: node still queryable after remove-node" };
|
|
}
|
|
} catch {
|
|
}
|
|
return { ok: true };
|
|
}
|
|
function readScalarFromPropertyDump(dump) {
|
|
const inner = dump.value;
|
|
if (typeof inner === "boolean" || typeof inner === "number" || typeof inner === "string") {
|
|
return inner;
|
|
}
|
|
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
const v = inner.value;
|
|
if (typeof v === "boolean" || typeof v === "number" || typeof v === "string") {
|
|
return v;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function applyScalarToPropertyDump(dump, v) {
|
|
const inner = dump.value;
|
|
if (typeof inner === "boolean" && typeof v === "boolean") {
|
|
dump.value = v;
|
|
return;
|
|
}
|
|
if (typeof inner === "number" && typeof v === "number") {
|
|
dump.value = v;
|
|
return;
|
|
}
|
|
if (typeof inner === "string" && typeof v === "string") {
|
|
dump.value = v;
|
|
return;
|
|
}
|
|
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
const o = inner;
|
|
if (typeof o.value === "boolean" && typeof v === "boolean") {
|
|
o.value = v;
|
|
return;
|
|
}
|
|
if (typeof o.value === "number" && typeof v === "number") {
|
|
o.value = v;
|
|
return;
|
|
}
|
|
if (typeof o.value === "string" && typeof v === "string") {
|
|
o.value = v;
|
|
return;
|
|
}
|
|
}
|
|
dump.value = v;
|
|
}
|
|
function readComparableValueFromPropertyDump(dump) {
|
|
const inner = dump.value;
|
|
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
const v = inner.value;
|
|
if (v !== void 0) {
|
|
return v;
|
|
}
|
|
}
|
|
return inner;
|
|
}
|
|
function valuesEqualForChange(a, b) {
|
|
if (Object.is(a, b)) {
|
|
return true;
|
|
}
|
|
try {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
function toDebugScalar(v) {
|
|
try {
|
|
const s = JSON.stringify(v);
|
|
return s.length > 180 ? `${s.slice(0, 180)}...` : s;
|
|
} catch {
|
|
return String(v);
|
|
}
|
|
}
|
|
async function applyInspectorComponentProperty(uuid, propPath, dump, record) {
|
|
try {
|
|
const ok = await Editor.Message.request(
|
|
"scene",
|
|
"set-property",
|
|
{
|
|
uuid,
|
|
path: propPath,
|
|
dump,
|
|
record
|
|
}
|
|
);
|
|
if (!ok) {
|
|
return { ok: false, message: "set-property returned false" };
|
|
}
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}
|
|
function cloneActiveDump(active) {
|
|
try {
|
|
return structuredClone(active);
|
|
} catch {
|
|
return JSON.parse(JSON.stringify(active));
|
|
}
|
|
}
|
|
function readActiveFromPropertyDump(dump) {
|
|
const inner = dump.value;
|
|
if (inner === true || inner === false) {
|
|
return inner;
|
|
}
|
|
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
const o = inner;
|
|
if (typeof o.value === "boolean") {
|
|
return o.value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function applyActiveValueToPropertyDump(dump, next) {
|
|
const inner = dump.value;
|
|
if (inner === true || inner === false) {
|
|
dump.value = next;
|
|
return;
|
|
}
|
|
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
const o = inner;
|
|
if (typeof o.value === "boolean") {
|
|
o.value = next;
|
|
return;
|
|
}
|
|
}
|
|
dump.value = next;
|
|
}
|
|
async function sceneCommitUndoSnapshot() {
|
|
try {
|
|
await Editor.Message.request("scene", "snapshot");
|
|
} catch {
|
|
}
|
|
}
|
|
async function applyInspectorNodeTransform(uuid, target, record) {
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", uuid);
|
|
const paths = ["position", "rotation", "scale"];
|
|
const keys = ["position", "rotation", "scale"];
|
|
for (let i = 0; i < 3; i += 1) {
|
|
const p = paths[i];
|
|
const raw = inode[p];
|
|
if (!raw || typeof raw !== "object") {
|
|
return { ok: false, message: `query-node: missing ${p} dump` };
|
|
}
|
|
}
|
|
for (let i = 0; i < 3; i += 1) {
|
|
const p = paths[i];
|
|
const key = keys[i];
|
|
const raw = inode[p];
|
|
const dump = cloneActiveDump(raw);
|
|
applyVec3ToPropertyDump(dump, target[key]);
|
|
const ok = await Editor.Message.request(
|
|
"scene",
|
|
"set-property",
|
|
{
|
|
uuid,
|
|
path: p,
|
|
dump,
|
|
record
|
|
}
|
|
);
|
|
if (!ok) {
|
|
return { ok: false, message: `set-property returned false for ${p}` };
|
|
}
|
|
}
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}
|
|
async function applyInspectorNodeActive(uuid, nextActive, record) {
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", uuid);
|
|
if (!inode?.active || typeof inode.active !== "object") {
|
|
return { ok: false, message: "query-node: missing active property dump" };
|
|
}
|
|
const dump = cloneActiveDump(inode.active);
|
|
applyActiveValueToPropertyDump(dump, nextActive);
|
|
const ok = await Editor.Message.request(
|
|
"scene",
|
|
"set-property",
|
|
{
|
|
uuid,
|
|
path: "active",
|
|
dump,
|
|
record
|
|
}
|
|
);
|
|
if (!ok) {
|
|
return { ok: false, message: "set-property returned false" };
|
|
}
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}
|
|
async function applyInspectorNodeName(uuid, nextName, record) {
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", uuid);
|
|
if (!inode?.name || typeof inode.name !== "object") {
|
|
return { ok: false, message: "query-node: missing name property dump" };
|
|
}
|
|
const dump = cloneActiveDump(inode.name);
|
|
applyScalarToPropertyDump(dump, nextName);
|
|
const ok = await Editor.Message.request(
|
|
"scene",
|
|
"set-property",
|
|
{
|
|
uuid,
|
|
path: "name",
|
|
dump,
|
|
record
|
|
}
|
|
);
|
|
if (!ok) {
|
|
return { ok: false, message: "set-property returned false" };
|
|
}
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}
|
|
async function handleEditorUndo(id) {
|
|
const Ed = Editor;
|
|
if (typeof Ed.Undo?.undo !== "function") {
|
|
return {
|
|
op: "editor_undo_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "Editor.Undo.undo is not available in this editor build. Use MCP tool `cocos_mcp_undo_last` for the last MCP write (active/name/create/instantiate_prefab/delete/reparent/transform/component property)."
|
|
};
|
|
}
|
|
try {
|
|
await Ed.Undo.undo();
|
|
const result = { ok: true, via: "Editor.Undo" };
|
|
return { op: "editor_undo_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "editor_undo_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
async function handleMcpUndoLast(id) {
|
|
let activeSceneUuid = "";
|
|
try {
|
|
const meta = await executeSceneMethod("getCurrentSceneMeta", []);
|
|
if (inodeRecord(meta) && meta.ok === true && meta.hasScene === true && typeof meta.sceneAssetUuid === "string") {
|
|
activeSceneUuid = meta.sceneAssetUuid;
|
|
}
|
|
} catch {
|
|
}
|
|
if (!activeSceneUuid) {
|
|
return {
|
|
op: "mcp_undo_last_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "The active scene is temporarily unavailable. The MCP undo stack was preserved; retry after the scene loads."
|
|
};
|
|
}
|
|
if (!mcpUndoSceneUuid && mcpUndoStack.length > 0) {
|
|
return {
|
|
op: "mcp_undo_last_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "The MCP undo stack is not bound to a scene. It was preserved; refresh hierarchy before retrying."
|
|
};
|
|
}
|
|
if (mcpUndoSceneUuid && activeSceneUuid !== mcpUndoSceneUuid) {
|
|
await clearMcpUndoStack();
|
|
return {
|
|
op: "mcp_undo_last_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "The active scene changed. The stale MCP undo stack was cleared; refresh hierarchy before writing again."
|
|
};
|
|
}
|
|
mcpUndoSceneUuid = activeSceneUuid;
|
|
const entry = mcpUndoStack.pop();
|
|
if (!entry) {
|
|
return {
|
|
op: "mcp_undo_last_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "No MCP write to undo (stack empty, or only no-op writes were recorded)."
|
|
};
|
|
}
|
|
if (entry.kind === "ui_transaction") {
|
|
const removed = await removeUiTransactionCreatedNodes(entry.entries, entry.sceneUuid);
|
|
if (!removed.ok) {
|
|
if (!removed.terminal && removed.residualEntries.length > 0) {
|
|
mcpUndoStack.push({ ...entry, entries: removed.residualEntries });
|
|
}
|
|
return {
|
|
op: "mcp_undo_last_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `UI transaction undo failed: ${removed.message}`
|
|
};
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "ui_transaction"
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "set_node_active") {
|
|
const applied = await applyInspectorNodeActive(entry.uuid, entry.previousActive, false);
|
|
if (!applied.ok) {
|
|
mcpUndoStack.push(entry);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: applied.message };
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "set_node_active",
|
|
active: entry.previousActive
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "set_node_name") {
|
|
const appliedN = await applyInspectorNodeName(entry.uuid, entry.previousName, false);
|
|
if (!appliedN.ok) {
|
|
mcpUndoStack.push(entry);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: appliedN.message };
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "set_node_name",
|
|
name: entry.previousName
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "create_node") {
|
|
try {
|
|
await Editor.Message.request("scene", "remove-node", { uuid: entry.uuid });
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
mcpUndoStack.push(entry);
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
try {
|
|
await executeSceneMethod("forgetNodeNid", [entry.uuid]);
|
|
} catch {
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "create_node"
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "instantiate_prefab") {
|
|
try {
|
|
await Editor.Message.request("scene", "remove-node", { uuid: entry.uuid });
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
mcpUndoStack.push(entry);
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
try {
|
|
await executeSceneMethod("forgetNodeNid", [entry.uuid]);
|
|
} catch {
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "instantiate_prefab"
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "add_component") {
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", entry.uuid);
|
|
if (!inodeRecord(inode)) {
|
|
throw new Error("query-node failed for add_component undo target");
|
|
}
|
|
const beforeComps = readComponentDumps(inode);
|
|
const beforeCount = beforeComps.length;
|
|
const normalizedType = normalizeComponentTypeName(entry.componentType);
|
|
const beforeTypeCount = beforeComps.filter((c) => {
|
|
const t = readComponentTypeFromDump(c);
|
|
return Boolean(t && normalizeComponentTypeName(t) === normalizedType);
|
|
}).length;
|
|
if (beforeTypeCount === 0) {
|
|
await restoreComponentTypeBaselineAfterAdd(entry);
|
|
await sceneCommitUndoSnapshot();
|
|
const result2 = { ok: true, nid: entry.nid, via: "mcp_local_stack", kind: "add_component" };
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result: result2 };
|
|
}
|
|
const preferredIndex = pickUndoAddComponentIndex(beforeComps, entry);
|
|
const preferredDump = beforeComps[preferredIndex] ?? entry.addedComponentDump;
|
|
const componentUuid = entry.componentUuid ?? readComponentUuidFromDump(entry.addedComponentDump) ?? readComponentUuidFromDump(preferredDump);
|
|
let removed = false;
|
|
let lastAfterCount = beforeCount;
|
|
let lastAfterTypeCount = beforeTypeCount;
|
|
const tried = componentUuid ? `componentUuid=${componentUuid}` : "componentUuid=unavailable";
|
|
if (componentUuid) {
|
|
const exactBefore = await getRuntimeComponentUuidState(entry.uuid, componentUuid);
|
|
if (!exactBefore) {
|
|
throw new Error(`cannot verify exact component UUID before undo (${componentUuid})`);
|
|
}
|
|
if (!exactBefore.present) {
|
|
removed = true;
|
|
} else {
|
|
try {
|
|
await Editor.Message.request("scene", "remove-component", { uuid: componentUuid });
|
|
} catch {
|
|
}
|
|
const exactAfter = await getRuntimeComponentUuidState(entry.uuid, componentUuid);
|
|
if (!exactAfter) {
|
|
throw new Error(`component removal outcome is ambiguous for UUID ${componentUuid}`);
|
|
}
|
|
removed = !exactAfter.present;
|
|
try {
|
|
const inodeAfterTry = await Editor.Message.request("scene", "query-node", entry.uuid);
|
|
if (inodeRecord(inodeAfterTry)) {
|
|
const afterComps = readComponentDumps(inodeAfterTry);
|
|
lastAfterCount = afterComps.length;
|
|
lastAfterTypeCount = afterComps.filter((c) => {
|
|
const t = readComponentTypeFromDump(c);
|
|
return Boolean(t && normalizeComponentTypeName(t) === normalizedType);
|
|
}).length;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
if (!removed && !componentUuid) {
|
|
try {
|
|
const fb = await executeSceneMethod("removeLastComponentByType", [
|
|
entry.uuid,
|
|
entry.componentType
|
|
]);
|
|
if (inodeRecord(fb) && fb.ok === true && typeof fb.removed === "boolean" && fb.removed === true) {
|
|
removed = true;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
if (!removed && !componentUuid) {
|
|
try {
|
|
const stats = await executeSceneMethod("getComponentStatsByType", [
|
|
entry.uuid,
|
|
entry.componentType
|
|
]);
|
|
if (inodeRecord(stats) && stats.ok === true && typeof stats.typeCount === "number" && Number.isFinite(stats.typeCount) && stats.typeCount < beforeTypeCount) {
|
|
removed = true;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
if (!removed) {
|
|
throw new Error(
|
|
`undo add_component made no structural change (tried=[${tried}], beforeCount=${beforeCount}, afterCount=${lastAfterCount}, beforeTypeCount=${beforeTypeCount}, afterTypeCount=${lastAfterTypeCount})`
|
|
);
|
|
}
|
|
await restoreComponentTypeBaselineAfterAdd(entry);
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
mcpUndoStack.push(entry);
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
const result = { ok: true, nid: entry.nid, via: "mcp_local_stack", kind: "add_component" };
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "remove_component") {
|
|
try {
|
|
if (entry.componentUuid) {
|
|
const exactState = await getRuntimeComponentUuidState(entry.uuid, entry.componentUuid);
|
|
if (!exactState) {
|
|
throw new Error(`cannot verify removed component UUID before undo (${entry.componentUuid})`);
|
|
}
|
|
if (exactState.present) {
|
|
const result2 = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "remove_component"
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result: result2 };
|
|
}
|
|
}
|
|
const beforeRaw = await Editor.Message.request("scene", "query-node", entry.uuid);
|
|
const beforeComps = inodeRecord(beforeRaw) ? readComponentDumps(beforeRaw) : [];
|
|
const beforeCount = beforeComps.length;
|
|
const normalizedType = normalizeComponentTypeName(entry.componentType);
|
|
const beforeTypeCount = beforeComps.filter((c) => {
|
|
const t = readComponentTypeFromDump(c);
|
|
return Boolean(t && normalizeComponentTypeName(t) === normalizedType);
|
|
}).length;
|
|
const componentClassName = entry.componentType.startsWith("cc.") ? entry.componentType : `cc.${entry.componentType}`;
|
|
const beforeRuntimeStats = await getRuntimeComponentStats(entry.uuid, componentClassName);
|
|
const beforeRuntimeTypeCount = beforeRuntimeStats?.typeCount ?? null;
|
|
let createError = null;
|
|
let fallbackError = null;
|
|
try {
|
|
await Editor.Message.request("scene", "create-component", {
|
|
uuid: entry.uuid,
|
|
component: componentClassName,
|
|
record: true
|
|
});
|
|
} catch (e) {
|
|
createError = e instanceof Error ? e.message : String(e);
|
|
}
|
|
let restored = await waitRuntimeTypeCountAbove(entry.uuid, entry.componentType, beforeRuntimeTypeCount);
|
|
if (!restored) {
|
|
try {
|
|
const fb = await executeSceneMethod("addLastComponentByType", [entry.uuid, componentClassName]);
|
|
if (inodeRecord(fb) && fb.ok === true && typeof fb.added === "boolean" && fb.added === true) {
|
|
restored = true;
|
|
} else if (inodeRecord(fb) && fb.ok === false && typeof fb.message === "string") {
|
|
fallbackError = fb.message;
|
|
}
|
|
} catch (e) {
|
|
fallbackError = e instanceof Error ? e.message : String(e);
|
|
}
|
|
}
|
|
if (!restored) {
|
|
restored = await waitRuntimeTypeCountAbove(entry.uuid, entry.componentType, beforeRuntimeTypeCount);
|
|
}
|
|
const inode = await Editor.Message.request("scene", "query-node", entry.uuid);
|
|
if (inodeRecord(inode)) {
|
|
const comps = readComponentDumps(inode);
|
|
const idx = Math.max(0, Math.min(entry.removedComponentIndex, Math.max(0, comps.length - 1)));
|
|
const restore = entry.removedComponentDump;
|
|
for (const [prop, dump] of Object.entries(restore)) {
|
|
if (prop.startsWith("__")) {
|
|
continue;
|
|
}
|
|
if (!inodeRecord(dump)) {
|
|
continue;
|
|
}
|
|
try {
|
|
await Editor.Message.request("scene", "set-property", {
|
|
uuid: entry.uuid,
|
|
path: `__comps__.${idx}.${prop}`,
|
|
dump: cloneActiveDump(dump),
|
|
record: false
|
|
});
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
const afterRaw = await Editor.Message.request("scene", "query-node", entry.uuid);
|
|
const afterComps = inodeRecord(afterRaw) ? readComponentDumps(afterRaw) : [];
|
|
const afterCount = afterComps.length;
|
|
const afterTypeCount = afterComps.filter((c) => {
|
|
const t = readComponentTypeFromDump(c);
|
|
return Boolean(t && normalizeComponentTypeName(t) === normalizedType);
|
|
}).length;
|
|
const afterRuntimeStats = await getRuntimeComponentStats(entry.uuid, componentClassName);
|
|
const runtimeRestored = beforeRuntimeStats !== null && afterRuntimeStats !== null && (afterRuntimeStats.totalCount > beforeRuntimeStats.totalCount || afterRuntimeStats.typeCount > beforeRuntimeStats.typeCount);
|
|
if (!(afterCount > beforeCount || afterTypeCount > beforeTypeCount || runtimeRestored)) {
|
|
throw new Error(
|
|
`undo remove_component produced no observable restore (type=${entry.componentType}, beforeCount=${beforeCount}, afterCount=${afterCount}, beforeTypeCount=${beforeTypeCount}, afterTypeCount=${afterTypeCount}, createError=${createError ?? "none"}, fallbackError=${fallbackError ?? "none"})`
|
|
);
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
mcpUndoStack.push(entry);
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "remove_component"
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "duplicate_node") {
|
|
try {
|
|
await Editor.Message.request("scene", "remove-node", { uuid: entry.uuid });
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
mcpUndoStack.push(entry);
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
try {
|
|
await executeSceneMethod("forgetNodeNid", [entry.uuid]);
|
|
} catch {
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "duplicate_node"
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "delete_node") {
|
|
try {
|
|
const restoredRaw = await executeSceneMethod("restoreDetachedNodeForMcpUndo", [entry.uuid]);
|
|
if (!(inodeRecord(restoredRaw) && restoredRaw.ok === true && restoredRaw.uuid === entry.uuid)) {
|
|
const detail = inodeRecord(restoredRaw) && typeof restoredRaw.message === "string" ? restoredRaw.message : "unexpected response";
|
|
const error = new Error(`restoreDetachedNodeForMcpUndo failed: ${detail}`);
|
|
error.terminal = inodeRecord(restoredRaw) && restoredRaw.terminal === true;
|
|
throw error;
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
if (!(e instanceof Error && e.terminal === true)) {
|
|
mcpUndoStack.push(entry);
|
|
}
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
try {
|
|
for (const item of entry.subtreeMappings) {
|
|
await executeSceneMethod("restoreNidMapping", [item.uuid, item.nid]);
|
|
}
|
|
} catch {
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "delete_node"
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "reparent_node") {
|
|
try {
|
|
await Editor.Message.request("scene", "set-parent", {
|
|
parent: entry.previousParentUuid,
|
|
uuids: entry.childUuid,
|
|
keepWorldTransform: entry.keepWorldTransform
|
|
});
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
mcpUndoStack.push(entry);
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.childNid,
|
|
via: "mcp_local_stack",
|
|
kind: "reparent_node"
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "set_node_sibling_index") {
|
|
try {
|
|
try {
|
|
await Editor.Message.request("scene", "move-node", {
|
|
uuid: entry.uuid,
|
|
parentUuid: entry.parentUuid,
|
|
insertIndex: entry.previousIndex,
|
|
keepWorldTransform: false
|
|
});
|
|
} catch {
|
|
const fb = await executeSceneMethod("setSiblingIndexFallback", [
|
|
entry.uuid,
|
|
entry.previousIndex
|
|
]);
|
|
if (!(inodeRecord(fb) && fb.ok === true)) {
|
|
throw new Error("setSiblingIndexFallback failed during undo");
|
|
}
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
mcpUndoStack.push(entry);
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "set_node_sibling_index"
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "set_component_property") {
|
|
const appliedC = await applyInspectorComponentProperty(
|
|
entry.uuid,
|
|
entry.path,
|
|
entry.previousDump,
|
|
false
|
|
);
|
|
if (!appliedC.ok) {
|
|
mcpUndoStack.push(entry);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: appliedC.message };
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "set_component_property",
|
|
propertyPath: entry.path
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "set_component_scalar_direct") {
|
|
try {
|
|
const restored = await executeSceneMethod("setComponentScalarPropertyDirect", [
|
|
entry.uuid,
|
|
entry.componentIndex,
|
|
entry.property,
|
|
entry.previousValue
|
|
]);
|
|
if (!inodeRecord(restored) || restored.ok !== true || !valuesEqualForChange(restored.afterValue, entry.previousValue)) {
|
|
throw new Error(`runtime-direct scalar restore failed for ${entry.property}`);
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
mcpUndoStack.push(entry);
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "set_component_property",
|
|
propertyPath: `[runtime-direct-value].${entry.property}`
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "set_component_asset_direct") {
|
|
try {
|
|
if (entry.previousAssetUuid) {
|
|
const restore = await executeSceneMethod("setComponentAssetRefDirect", [
|
|
entry.uuid,
|
|
entry.componentType,
|
|
entry.property,
|
|
entry.previousAssetUuid
|
|
]);
|
|
if (!(inodeRecord(restore) && restore.ok === true)) {
|
|
throw new Error("setComponentAssetRefDirect restore failed");
|
|
}
|
|
} else {
|
|
const cleared = await executeSceneMethod("clearComponentAssetRef", [
|
|
entry.uuid,
|
|
entry.componentType,
|
|
entry.property
|
|
]);
|
|
if (!(inodeRecord(cleared) && cleared.ok === true)) {
|
|
throw new Error("clearComponentAssetRef restore failed");
|
|
}
|
|
}
|
|
const verifyRaw = await executeSceneMethod("getComponentAssetRefUuid", [
|
|
entry.uuid,
|
|
entry.componentType,
|
|
entry.property
|
|
]);
|
|
if (inodeRecord(verifyRaw) && verifyRaw.ok === true) {
|
|
const actual = typeof verifyRaw.uuid === "string" ? verifyRaw.uuid : null;
|
|
const expected = entry.previousAssetUuid ?? null;
|
|
if (actual !== expected) {
|
|
throw new Error(
|
|
`undo set_component_property runtime mismatch (expected=${String(expected)}, actual=${String(actual)})`
|
|
);
|
|
}
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
} catch (e) {
|
|
mcpUndoStack.push(entry);
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "set_component_property",
|
|
propertyPath: `${entry.componentType}.${entry.property}`
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
if (entry.kind === "set_node_transform") {
|
|
const appliedT = await applyInspectorNodeTransform(entry.uuid, entry.previous, false);
|
|
if (!appliedT.ok) {
|
|
mcpUndoStack.push(entry);
|
|
return { op: "mcp_undo_last_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: appliedT.message };
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: entry.nid,
|
|
via: "mcp_local_stack",
|
|
kind: "set_node_transform",
|
|
reverted: ["position", "rotation", "scale"]
|
|
};
|
|
return { op: "mcp_undo_last_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
mcpUndoStack.push(entry);
|
|
return {
|
|
op: "mcp_undo_last_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "Internal error: unknown MCP undo entry kind."
|
|
};
|
|
}
|
|
async function handleSetNodeActive(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareSetNodeActive", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_node_active_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_set_node_active_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isSetNodeActivePrepareOk(prepRaw)) {
|
|
return {
|
|
op: "scene_set_node_active_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareSetNodeActive returned unexpected shape"
|
|
};
|
|
}
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", prepRaw.uuid);
|
|
if (!inode?.active || typeof inode.active !== "object") {
|
|
return {
|
|
op: "scene_set_node_active_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "query-node: missing active property dump"
|
|
};
|
|
}
|
|
const dump = cloneActiveDump(inode.active);
|
|
let previousActive = readActiveFromPropertyDump(dump);
|
|
if (previousActive === null) {
|
|
try {
|
|
const live = await executeSceneMethod("getNodeActiveState", [prepRaw.uuid]);
|
|
if (inodeRecord(live) && live.ok === true && typeof live.active === "boolean") {
|
|
previousActive = live.active;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
applyActiveValueToPropertyDump(dump, payload.active);
|
|
const ok = await Editor.Message.request(
|
|
"scene",
|
|
"set-property",
|
|
{
|
|
uuid: prepRaw.uuid,
|
|
path: "active",
|
|
dump,
|
|
record: true
|
|
}
|
|
);
|
|
if (!ok) {
|
|
return {
|
|
op: "scene_set_node_active_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "set-property returned false"
|
|
};
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
if (previousActive !== null && previousActive !== payload.active) {
|
|
mcpUndoStack.push({
|
|
kind: "set_node_active",
|
|
uuid: prepRaw.uuid,
|
|
nid: payload.nid,
|
|
previousActive
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
}
|
|
const result = { ok: true, nid: payload.nid, active: payload.active };
|
|
return { op: "scene_set_node_active_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_node_active_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
async function handleSetNodeName(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareSetNodeName", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_node_name_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_set_node_name_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareSetNodeNameOk(prepRaw)) {
|
|
return {
|
|
op: "scene_set_node_name_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareSetNodeName returned unexpected shape"
|
|
};
|
|
}
|
|
const prep = prepRaw;
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", prep.uuid);
|
|
if (!inode?.name || typeof inode.name !== "object") {
|
|
return {
|
|
op: "scene_set_node_name_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "query-node: missing name property dump"
|
|
};
|
|
}
|
|
const nameDump = inode.name;
|
|
const previousScalar = readScalarFromPropertyDump(nameDump);
|
|
if (typeof previousScalar !== "string") {
|
|
return {
|
|
op: "scene_set_node_name_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "query-node: name is not a string scalar"
|
|
};
|
|
}
|
|
const dump = cloneActiveDump(nameDump);
|
|
applyScalarToPropertyDump(dump, prep.name);
|
|
const ok = await Editor.Message.request(
|
|
"scene",
|
|
"set-property",
|
|
{
|
|
uuid: prep.uuid,
|
|
path: "name",
|
|
dump,
|
|
record: true
|
|
}
|
|
);
|
|
if (!ok) {
|
|
return {
|
|
op: "scene_set_node_name_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "set-property returned false"
|
|
};
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
if (previousScalar !== prep.name) {
|
|
mcpUndoStack.push({
|
|
kind: "set_node_name",
|
|
uuid: prep.uuid,
|
|
nid: payload.nid,
|
|
previousName: previousScalar
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
}
|
|
const result = { ok: true, nid: payload.nid, name: prep.name };
|
|
return { op: "scene_set_node_name_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_node_name_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
async function handleCreateNode(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareCreateNode", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_create_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_create_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareCreateNodeOk(prepRaw)) {
|
|
return {
|
|
op: "scene_create_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareCreateNode returned unexpected shape"
|
|
};
|
|
}
|
|
const prep = prepRaw;
|
|
let newUuid;
|
|
try {
|
|
newUuid = await Editor.Message.request(
|
|
"scene",
|
|
"create-node",
|
|
{
|
|
parent: prep.parentUuid,
|
|
name: prep.name,
|
|
snapshot: true
|
|
}
|
|
);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_create_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (typeof newUuid !== "string" || newUuid.length === 0) {
|
|
return {
|
|
op: "scene_create_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "create-node did not return a non-empty uuid string"
|
|
};
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
let regRaw;
|
|
try {
|
|
regRaw = await executeSceneMethod("registerNidForNewNode", [newUuid]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
const rollback = await rollbackCreatedNodeWithVerification(newUuid);
|
|
if (!rollback.ok) {
|
|
return {
|
|
op: "scene_create_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `partial_failure: create-node succeeded, register failed (${message}), and rollback failed (${rollback.message}).`
|
|
};
|
|
}
|
|
return {
|
|
op: "scene_create_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `registerNidForNewNode failed: ${message}`
|
|
};
|
|
}
|
|
if (!isRegisterNidForNewNodeOk(regRaw)) {
|
|
const rollback = await rollbackCreatedNodeWithVerification(newUuid);
|
|
if (!rollback.ok) {
|
|
return {
|
|
op: "scene_create_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `partial_failure: create-node succeeded, register returned unexpected shape, and rollback failed (${rollback.message}).`
|
|
};
|
|
}
|
|
return {
|
|
op: "scene_create_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "registerNidForNewNode returned unexpected shape"
|
|
};
|
|
}
|
|
mcpUndoStack.push({ kind: "create_node", uuid: newUuid, nid: regRaw.nid });
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
parentNid: payload.parentNid,
|
|
nid: regRaw.nid,
|
|
name: prep.name
|
|
};
|
|
return { op: "scene_create_node_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
async function handleDeleteNode(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareDeleteNode", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_delete_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_delete_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareDeleteNodeOk(prepRaw)) {
|
|
return {
|
|
op: "scene_delete_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareDeleteNode returned unexpected shape"
|
|
};
|
|
}
|
|
const prep = prepRaw;
|
|
let mappedEntriesRaw;
|
|
try {
|
|
mappedEntriesRaw = await executeSceneMethod("snapshotNidMappingsInSubtree", [prep.uuid]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return {
|
|
op: "scene_delete_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `snapshotNidMappingsInSubtree failed: ${message}`
|
|
};
|
|
}
|
|
const mappedEntries = inodeRecord(mappedEntriesRaw) && Array.isArray(mappedEntriesRaw.entries) ? mappedEntriesRaw.entries.filter((x) => typeof x.uuid === "string" && typeof x.nid === "number").map((x) => ({ uuid: x.uuid, nid: x.nid })) : [];
|
|
try {
|
|
const detached = await executeSceneMethod("detachNodeForMcpUndo", [prep.uuid]);
|
|
if (!(inodeRecord(detached) && detached.ok === true && detached.uuid === prep.uuid)) {
|
|
const detail = inodeRecord(detached) && typeof detached.message === "string" ? detached.message : "unexpected response";
|
|
return {
|
|
op: "scene_delete_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `detachNodeForMcpUndo failed: ${detail}`
|
|
};
|
|
}
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_delete_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
try {
|
|
await executeSceneMethod(
|
|
"forgetNidMappingsByUuids",
|
|
[mappedEntries.map((e) => e.uuid)]
|
|
);
|
|
} catch {
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
mcpUndoStack.push({
|
|
kind: "delete_node",
|
|
uuid: prep.uuid,
|
|
nid: payload.nid,
|
|
subtreeMappings: mappedEntries
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
const result = { ok: true, nid: payload.nid };
|
|
return { op: "scene_delete_node_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
async function handleReparentNode(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareReparentNode", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_reparent_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_reparent_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareReparentNodeOk(prepRaw)) {
|
|
return {
|
|
op: "scene_reparent_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareReparentNode returned unexpected shape"
|
|
};
|
|
}
|
|
const prep = prepRaw;
|
|
if (!prep.noOp) {
|
|
try {
|
|
await Editor.Message.request("scene", "set-parent", {
|
|
parent: prep.newParentUuid,
|
|
uuids: prep.childUuid,
|
|
keepWorldTransform: prep.keepWorldTransform
|
|
});
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_reparent_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
mcpUndoStack.push({
|
|
kind: "reparent_node",
|
|
childUuid: prep.childUuid,
|
|
childNid: payload.childNid,
|
|
previousParentUuid: prep.previousParentUuid,
|
|
keepWorldTransform: prep.keepWorldTransform
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
childNid: payload.childNid,
|
|
parentNid: payload.parentNid
|
|
};
|
|
if (prep.noOp) {
|
|
result.unchanged = true;
|
|
}
|
|
return { op: "scene_reparent_node_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
async function handleSetNodeSiblingIndex(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareSetNodeSiblingIndex", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_node_sibling_index_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_set_node_sibling_index_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareSetNodeSiblingIndexOk(prepRaw)) {
|
|
return {
|
|
op: "scene_set_node_sibling_index_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareSetNodeSiblingIndex returned unexpected shape"
|
|
};
|
|
}
|
|
const prep = prepRaw;
|
|
if (!prep.noOp) {
|
|
try {
|
|
try {
|
|
await Editor.Message.request("scene", "move-node", {
|
|
uuid: prep.uuid,
|
|
parentUuid: prep.parentUuid,
|
|
insertIndex: prep.index,
|
|
keepWorldTransform: false
|
|
});
|
|
} catch {
|
|
const fb = await executeSceneMethod("setSiblingIndexFallback", [
|
|
prep.uuid,
|
|
prep.index
|
|
]);
|
|
if (!(inodeRecord(fb) && fb.ok === true)) {
|
|
throw new Error("setSiblingIndexFallback failed");
|
|
}
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
mcpUndoStack.push({
|
|
kind: "set_node_sibling_index",
|
|
uuid: prep.uuid,
|
|
nid: payload.nid,
|
|
parentUuid: prep.parentUuid,
|
|
previousIndex: prep.previousIndex
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_node_sibling_index_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
const result = { ok: true, nid: payload.nid, index: prep.index };
|
|
if (prep.noOp) {
|
|
result.unchanged = true;
|
|
}
|
|
return { op: "scene_set_node_sibling_index_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
async function readSceneNodeDisplayName(nodeUuid) {
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", nodeUuid);
|
|
if (inode?.name && typeof inode.name === "object") {
|
|
const s = readScalarFromPropertyDump(inode.name);
|
|
if (typeof s === "string" && s.trim().length > 0) {
|
|
return s.trim();
|
|
}
|
|
}
|
|
} catch {
|
|
}
|
|
return "Instance";
|
|
}
|
|
async function queryPrefabAssetUuidForInstantiate(dbUrl) {
|
|
try {
|
|
const info = await Editor.Message.request("asset-db", "query-asset-info", dbUrl);
|
|
if (!info || typeof info.uuid !== "string" || info.uuid.length === 0) {
|
|
return { ok: false, message: "Prefab asset not found in asset-db (check path and import)." };
|
|
}
|
|
if (info.isDirectory === true) {
|
|
return { ok: false, message: "prefabPath must be a file, not a directory." };
|
|
}
|
|
if (info.invalid === true) {
|
|
return { ok: false, message: "Prefab asset is invalid or not finished importing." };
|
|
}
|
|
const ty = (info.type || "").toLowerCase();
|
|
const im = (info.importer || "").toLowerCase();
|
|
if (!ty.includes("prefab") && !im.includes("prefab")) {
|
|
return { ok: false, message: "Asset is not a prefab (unexpected type/importer)." };
|
|
}
|
|
return { ok: true, uuid: info.uuid };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { ok: false, message: `asset-db query failed: ${message}` };
|
|
}
|
|
}
|
|
async function handleInstantiatePrefab(id, payload) {
|
|
const pathNorm = normalizePrefabAssetRelativePath(payload.prefabPath);
|
|
if (!pathNorm.ok) {
|
|
return { op: "scene_instantiate_prefab_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: pathNorm.message };
|
|
}
|
|
const dbUrl = `db://${pathNorm.rel}`;
|
|
const assetRes = await queryPrefabAssetUuidForInstantiate(dbUrl);
|
|
if (!assetRes.ok) {
|
|
return { op: "scene_instantiate_prefab_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: assetRes.message };
|
|
}
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareInstantiatePrefab", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_instantiate_prefab_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_instantiate_prefab_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareInstantiatePrefabOk(prepRaw)) {
|
|
return {
|
|
op: "scene_instantiate_prefab_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareInstantiatePrefab returned unexpected shape"
|
|
};
|
|
}
|
|
const prep = prepRaw;
|
|
let newUuid;
|
|
try {
|
|
newUuid = await Editor.Message.request(
|
|
"scene",
|
|
"create-node",
|
|
{
|
|
parent: prep.parentUuid,
|
|
assetUuid: assetRes.uuid,
|
|
snapshot: true
|
|
}
|
|
);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_instantiate_prefab_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (typeof newUuid !== "string" || newUuid.length === 0) {
|
|
return {
|
|
op: "scene_instantiate_prefab_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "create-node did not return a non-empty uuid string"
|
|
};
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
let regRaw;
|
|
try {
|
|
regRaw = await executeSceneMethod("registerNidForNewNode", [newUuid]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
const rollback = await rollbackCreatedNodeWithVerification(newUuid);
|
|
if (!rollback.ok) {
|
|
return {
|
|
op: "scene_instantiate_prefab_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `partial_failure: instantiate succeeded, register failed (${message}), and rollback failed (${rollback.message}).`
|
|
};
|
|
}
|
|
return {
|
|
op: "scene_instantiate_prefab_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `registerNidForNewNode failed: ${message}`
|
|
};
|
|
}
|
|
if (!isRegisterNidForNewNodeOk(regRaw)) {
|
|
const rollback = await rollbackCreatedNodeWithVerification(newUuid);
|
|
if (!rollback.ok) {
|
|
return {
|
|
op: "scene_instantiate_prefab_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `partial_failure: instantiate succeeded, register returned unexpected shape, and rollback failed (${rollback.message}).`
|
|
};
|
|
}
|
|
return {
|
|
op: "scene_instantiate_prefab_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "registerNidForNewNode returned unexpected shape"
|
|
};
|
|
}
|
|
mcpUndoStack.push({ kind: "instantiate_prefab", uuid: newUuid, nid: regRaw.nid });
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
const nodeName = await readSceneNodeDisplayName(newUuid);
|
|
const result = {
|
|
ok: true,
|
|
parentNid: payload.parentNid,
|
|
nid: regRaw.nid,
|
|
prefabPath: pathNorm.rel,
|
|
nodeName
|
|
};
|
|
return { op: "scene_instantiate_prefab_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
async function handleAddComponent(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareAddComponent", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_add_component_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_add_component_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareAddComponentOk(prepRaw)) {
|
|
return {
|
|
op: "scene_add_component_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareAddComponent returned unexpected shape"
|
|
};
|
|
}
|
|
try {
|
|
const inodeBefore = await Editor.Message.request("scene", "query-node", prepRaw.uuid);
|
|
if (!inodeRecord(inodeBefore)) {
|
|
return { op: "scene_add_component_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: "query-node failed for target node" };
|
|
}
|
|
const beforeComps = readComponentDumps(inodeBefore);
|
|
const beforeSigs = new Set(beforeComps.map((c) => safeJsonSignature(c)));
|
|
const beforeCount = readComponentDumpCount(inodeBefore);
|
|
const beforeComponentTypeCounts = {};
|
|
for (const component of beforeComps) {
|
|
const type = readComponentTypeFromDump(component);
|
|
if (!type) continue;
|
|
const normalized = normalizeComponentTypeName(type);
|
|
beforeComponentTypeCounts[normalized] = (beforeComponentTypeCounts[normalized] ?? 0) + 1;
|
|
}
|
|
const requestedType = normalizeComponentTypeName(prepRaw.componentType);
|
|
const beforeRequestedTypeCount = beforeComps.filter((component) => {
|
|
const type = readComponentTypeFromDump(component);
|
|
return Boolean(type && normalizeComponentTypeName(type) === requestedType);
|
|
}).length;
|
|
const createRet = await Editor.Message.request("scene", "create-component", {
|
|
uuid: prepRaw.uuid,
|
|
component: prepRaw.componentType,
|
|
record: true
|
|
});
|
|
await sceneCommitUndoSnapshot();
|
|
const inodeAfter = await Editor.Message.request("scene", "query-node", prepRaw.uuid);
|
|
const afterCount = inodeRecord(inodeAfter) ? readComponentDumpCount(inodeAfter) : beforeCount;
|
|
const afterComps = inodeRecord(inodeAfter) ? readComponentDumps(inodeAfter) : [];
|
|
const afterRequestedTypeIndexes = afterComps.map((component, index) => ({ component, index })).filter(({ component }) => {
|
|
const type = readComponentTypeFromDump(component);
|
|
return Boolean(type && normalizeComponentTypeName(type) === requestedType);
|
|
}).map(({ index }) => index);
|
|
let addedComponentDump = {};
|
|
const addedCandidates = afterComps.filter((component) => !beforeSigs.has(safeJsonSignature(component)));
|
|
const requestedCandidate = addedCandidates.find((component) => {
|
|
const type = readComponentTypeFromDump(component);
|
|
return Boolean(type && normalizeComponentTypeName(type) === requestedType);
|
|
});
|
|
const hasStructuralChange = afterCount > beforeCount || addedCandidates.length > 0;
|
|
if (!hasStructuralChange) {
|
|
return {
|
|
op: "scene_add_component_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `create-component produced no observable component change (requested=${prepRaw.componentType}, return=${String(createRet)}).`
|
|
};
|
|
}
|
|
if (afterRequestedTypeIndexes.length <= beforeRequestedTypeCount) {
|
|
const addedTypes = addedCandidates.map((component) => readComponentTypeFromDump(component) ?? "unknown").join(",");
|
|
return {
|
|
op: "scene_add_component_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `create-component changed the node but did not add the requested type (requested=${prepRaw.componentType}, added=[${addedTypes || "unknown"}], return=${String(createRet)}).`
|
|
};
|
|
}
|
|
const componentIndex = requestedCandidate ? Math.max(
|
|
0,
|
|
afterComps.findIndex(
|
|
(component) => safeJsonSignature(component) === safeJsonSignature(requestedCandidate)
|
|
)
|
|
) : afterRequestedTypeIndexes[afterRequestedTypeIndexes.length - 1];
|
|
if (requestedCandidate) {
|
|
addedComponentDump = cloneActiveDump(requestedCandidate);
|
|
} else if (afterComps[componentIndex]) {
|
|
addedComponentDump = cloneActiveDump(afterComps[componentIndex]);
|
|
}
|
|
const actualType = readComponentTypeFromDump(addedComponentDump);
|
|
if (actualType && normalizeComponentTypeName(actualType) !== normalizeComponentTypeName(prepRaw.componentType)) {
|
|
return {
|
|
op: "scene_add_component_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `component type mismatch after create-component (expected=${prepRaw.componentType}, actual=${actualType})`
|
|
};
|
|
}
|
|
mcpUndoStack.push({
|
|
kind: "add_component",
|
|
uuid: prepRaw.uuid,
|
|
nid: payload.nid,
|
|
componentType: prepRaw.componentType,
|
|
componentIndex,
|
|
componentUuid: readComponentUuidFromDump(addedComponentDump) ?? void 0,
|
|
addedComponentDump,
|
|
addedComponentTypes: addedCandidates.map((component) => readComponentTypeFromDump(component)).filter((type) => Boolean(type)),
|
|
beforeComponentTypeCounts
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: payload.nid,
|
|
componentType: prepRaw.componentType,
|
|
componentIndex
|
|
};
|
|
return { op: "scene_add_component_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_add_component_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
async function handleRemoveComponent(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareRemoveComponent", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_remove_component_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_remove_component_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareRemoveComponentOk(prepRaw)) {
|
|
return {
|
|
op: "scene_remove_component_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareRemoveComponent returned unexpected shape"
|
|
};
|
|
}
|
|
if (typeof payload.expectedComponentType === "string" && payload.expectedComponentType.trim()) {
|
|
const expectedNorm = normalizeComponentTypeName(payload.expectedComponentType);
|
|
const actualNorm = normalizeComponentTypeName(prepRaw.componentType);
|
|
if (expectedNorm !== actualNorm) {
|
|
return {
|
|
op: "scene_remove_component_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `remove target type mismatch at index ${prepRaw.componentIndex}: expected=${payload.expectedComponentType}, actual=${prepRaw.componentType}. Refresh inspect and use the latest index.`
|
|
};
|
|
}
|
|
}
|
|
try {
|
|
const inodeBefore = await Editor.Message.request("scene", "query-node", prepRaw.uuid);
|
|
if (!inodeRecord(inodeBefore)) {
|
|
return { op: "scene_remove_component_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: "query-node failed for target node" };
|
|
}
|
|
const beforeComps = readComponentDumps(inodeBefore);
|
|
const beforeCount = beforeComps.length;
|
|
const normalizedType = normalizeComponentTypeName(prepRaw.componentType);
|
|
const beforeTypeCount = beforeComps.filter((c) => {
|
|
const t = readComponentTypeFromDump(c);
|
|
return Boolean(t && normalizeComponentTypeName(t) === normalizedType);
|
|
}).length;
|
|
const beforeRuntimeTypeCount = await getRuntimeComponentTypeCount(prepRaw.uuid, prepRaw.componentType);
|
|
const removedComponentDump = beforeComps[prepRaw.componentIndex] && inodeRecord(beforeComps[prepRaw.componentIndex]) ? cloneActiveDump(beforeComps[prepRaw.componentIndex]) : {};
|
|
const componentUuid = prepRaw.componentUuid ?? readComponentUuidFromDump(removedComponentDump);
|
|
let removeRet = void 0;
|
|
let removeIpcError = null;
|
|
if (componentUuid) {
|
|
try {
|
|
removeRet = await Editor.Message.request("scene", "remove-component", {
|
|
uuid: componentUuid
|
|
});
|
|
} catch (e) {
|
|
removeIpcError = e instanceof Error ? e.message : String(e);
|
|
}
|
|
} else {
|
|
removeIpcError = "component uuid unavailable; skipped official remove-component message";
|
|
}
|
|
const exactAfter = componentUuid ? await getRuntimeComponentUuidState(prepRaw.uuid, componentUuid) : null;
|
|
if (componentUuid && !exactAfter) {
|
|
mcpUndoStack.push({
|
|
kind: "remove_component",
|
|
uuid: prepRaw.uuid,
|
|
nid: payload.nid,
|
|
componentUuid,
|
|
componentType: prepRaw.componentType,
|
|
removedComponentIndex: prepRaw.componentIndex,
|
|
removedComponentDump
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
return {
|
|
op: "scene_remove_component_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `remove-component outcome is ambiguous for component UUID ${componentUuid}; a conservative cocos_mcp_undo_last entry was retained.`
|
|
};
|
|
}
|
|
let inodeAfter = null;
|
|
try {
|
|
inodeAfter = await Editor.Message.request("scene", "query-node", prepRaw.uuid);
|
|
} catch {
|
|
inodeAfter = null;
|
|
}
|
|
const afterComps = inodeRecord(inodeAfter) ? readComponentDumps(inodeAfter) : beforeComps;
|
|
const afterCount = afterComps.length;
|
|
const afterTypeCount = afterComps.filter((c) => {
|
|
const t = readComponentTypeFromDump(c);
|
|
return Boolean(t && normalizeComponentTypeName(t) === normalizedType);
|
|
}).length;
|
|
const afterRuntimeTypeCount = await getRuntimeComponentTypeCount(prepRaw.uuid, prepRaw.componentType);
|
|
const removedObserved = componentUuid ? exactAfter?.present === false : afterCount < beforeCount || afterTypeCount < beforeTypeCount || beforeRuntimeTypeCount !== null && afterRuntimeTypeCount !== null && afterRuntimeTypeCount < beforeRuntimeTypeCount;
|
|
const removedObservedAfterWait = removedObserved ? true : componentUuid ? false : await waitRuntimeTypeCountBelow(prepRaw.uuid, prepRaw.componentType, beforeRuntimeTypeCount);
|
|
if (!removedObservedAfterWait) {
|
|
const protectedNoOpTypes = /* @__PURE__ */ new Set(["sprite", "uitransform", "canvas", "camera", "directionallight"]);
|
|
if (!componentUuid && !protectedNoOpTypes.has(normalizedType)) {
|
|
try {
|
|
const fb = await executeSceneMethod("removeLastComponentByType", [
|
|
prepRaw.uuid,
|
|
prepRaw.componentType
|
|
]);
|
|
if (inodeRecord(fb) && fb.ok === true && typeof fb.removed === "boolean" && fb.removed === true) {
|
|
await sceneCommitUndoSnapshot();
|
|
mcpUndoStack.push({
|
|
kind: "remove_component",
|
|
uuid: prepRaw.uuid,
|
|
nid: payload.nid,
|
|
componentUuid: componentUuid ?? void 0,
|
|
componentType: prepRaw.componentType,
|
|
removedComponentIndex: prepRaw.componentIndex,
|
|
removedComponentDump
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
const result2 = {
|
|
ok: true,
|
|
nid: payload.nid,
|
|
componentIndex: prepRaw.componentIndex,
|
|
componentType: prepRaw.componentType
|
|
};
|
|
return { op: "scene_remove_component_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result: result2 };
|
|
}
|
|
const removedAfterFallbackWait = await waitRuntimeTypeCountBelow(
|
|
prepRaw.uuid,
|
|
prepRaw.componentType,
|
|
beforeRuntimeTypeCount
|
|
);
|
|
if (removedAfterFallbackWait) {
|
|
await sceneCommitUndoSnapshot();
|
|
mcpUndoStack.push({
|
|
kind: "remove_component",
|
|
uuid: prepRaw.uuid,
|
|
nid: payload.nid,
|
|
componentUuid: componentUuid ?? void 0,
|
|
componentType: prepRaw.componentType,
|
|
removedComponentIndex: prepRaw.componentIndex,
|
|
removedComponentDump
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
const result2 = {
|
|
ok: true,
|
|
nid: payload.nid,
|
|
componentIndex: prepRaw.componentIndex,
|
|
componentType: prepRaw.componentType
|
|
};
|
|
return { op: "scene_remove_component_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result: result2 };
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
return {
|
|
op: "scene_remove_component_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `remove-component produced no observable change (index=${prepRaw.componentIndex}, type=${prepRaw.componentType}, beforeCount=${beforeCount}, afterCount=${afterCount}, beforeTypeCount=${beforeTypeCount}, afterTypeCount=${afterTypeCount}, componentUuid=${componentUuid ?? "unavailable"}, removeRet=${String(removeRet)}, removeIpcError=${removeIpcError ?? "none"}).`
|
|
};
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
mcpUndoStack.push({
|
|
kind: "remove_component",
|
|
uuid: prepRaw.uuid,
|
|
nid: payload.nid,
|
|
componentUuid: componentUuid ?? void 0,
|
|
componentType: prepRaw.componentType,
|
|
removedComponentIndex: prepRaw.componentIndex,
|
|
removedComponentDump
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: payload.nid,
|
|
componentIndex: prepRaw.componentIndex,
|
|
componentType: prepRaw.componentType
|
|
};
|
|
return { op: "scene_remove_component_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_remove_component_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
async function handleDuplicateNode(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareDuplicateNode", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_duplicate_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_duplicate_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareDuplicateNodeOk(prepRaw)) {
|
|
return {
|
|
op: "scene_duplicate_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareDuplicateNode returned unexpected shape"
|
|
};
|
|
}
|
|
const childBefore = await getParentChildUuidSetForDuplicate(prepRaw.parentUuid);
|
|
let duplicateRet;
|
|
try {
|
|
duplicateRet = await Editor.Message.request("scene", "duplicate-node", {
|
|
uuid: prepRaw.uuid,
|
|
record: true
|
|
});
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_duplicate_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
let newUuid = typeof duplicateRet === "string" ? duplicateRet : typeof duplicateRet === "object" && duplicateRet !== null ? typeof duplicateRet.uuid === "string" ? duplicateRet.uuid ?? "" : Array.isArray(duplicateRet) && typeof duplicateRet[0] === "string" ? duplicateRet[0] : "" : "";
|
|
if (!newUuid) {
|
|
const childAfter = await getParentChildUuidSetForDuplicate(prepRaw.parentUuid);
|
|
const created = [...childAfter].filter((u) => !childBefore.has(u));
|
|
if (created.length === 1) {
|
|
newUuid = created[0];
|
|
panelLog(
|
|
"warn",
|
|
`duplicate-node returned non-standard value; recovered uuid by parent child-diff (uuid=${newUuid}).`
|
|
);
|
|
} else if (created.length > 1) {
|
|
const prefer = [...created].reverse().find((u) => u !== prepRaw.uuid);
|
|
newUuid = prefer ?? created[created.length - 1];
|
|
panelLog(
|
|
"warn",
|
|
`duplicate-node ambiguous child-diff (${created.length} new); picked last non-source uuid=${newUuid} raw=${safeJsonSignature(duplicateRet)}`
|
|
);
|
|
} else {
|
|
panelLog(
|
|
"warn",
|
|
`duplicate-node unresolved return; raw=${safeJsonSignature(duplicateRet)} recoveredCandidates=${safeJsonSignature(created)} beforeCount=${childBefore.size} afterCount=${childAfter.size}`
|
|
);
|
|
}
|
|
}
|
|
if (!newUuid) {
|
|
try {
|
|
const fb = await executeSceneMethod("duplicateNodeFallback", [prepRaw.uuid]);
|
|
if (inodeRecord(fb) && fb.ok === true && typeof fb.uuid === "string" && fb.uuid.length > 0) {
|
|
newUuid = fb.uuid;
|
|
panelLog(
|
|
"warn",
|
|
`duplicate-node IPC was a no-op; used scene duplicateNodeFallback (uuid=${newUuid}).`
|
|
);
|
|
}
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
panelLog("warn", `duplicateNodeFallback failed: ${message}`);
|
|
}
|
|
}
|
|
if (!newUuid) {
|
|
return {
|
|
op: "scene_duplicate_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `duplicate-node produced no new child under parent (ipc=${String(duplicateRet)}); and fallback duplicateNodeFallback failed. This Creator build likely does not support duplication.`
|
|
};
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
let regRaw;
|
|
try {
|
|
regRaw = await executeSceneMethod("registerNidForNewNode", [newUuid]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
const rollback = await rollbackCreatedNodeWithVerification(newUuid);
|
|
if (!rollback.ok) {
|
|
return {
|
|
op: "scene_duplicate_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `partial_failure: duplicate succeeded, register failed (${message}), and rollback failed (${rollback.message}).`
|
|
};
|
|
}
|
|
return {
|
|
op: "scene_duplicate_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `registerNidForNewNode failed: ${message}`
|
|
};
|
|
}
|
|
if (!isRegisterNidForNewNodeOk(regRaw)) {
|
|
const rollback = await rollbackCreatedNodeWithVerification(newUuid);
|
|
if (!rollback.ok) {
|
|
return {
|
|
op: "scene_duplicate_node_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `partial_failure: duplicate succeeded, register returned unexpected shape, and rollback failed (${rollback.message}).`
|
|
};
|
|
}
|
|
return { op: "scene_duplicate_node_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: "registerNidForNewNode returned unexpected shape" };
|
|
}
|
|
let nodeName = "DuplicatedNode";
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", newUuid);
|
|
if (inodeRecord(inode) && typeof inode.name === "string") {
|
|
nodeName = inode.name;
|
|
}
|
|
} catch {
|
|
}
|
|
mcpUndoStack.push({ kind: "duplicate_node", uuid: newUuid, nid: regRaw.nid });
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
const result = { ok: true, sourceNid: payload.nid, nid: regRaw.nid, name: nodeName };
|
|
return { op: "scene_duplicate_node_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
async function handleSaveScene(id) {
|
|
try {
|
|
const meta = await executeSceneMethod("getCurrentSceneMeta", []);
|
|
if (!meta || meta.ok !== true || meta.hasScene !== true || typeof meta.sceneAssetUuid !== "string") {
|
|
return {
|
|
op: "scene_save_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "No active named scene to save."
|
|
};
|
|
}
|
|
const info = await Editor.Message.request(
|
|
"asset-db",
|
|
"query-asset-info",
|
|
meta.sceneAssetUuid
|
|
);
|
|
if (!info || typeof info.file !== "string" || info.file.length === 0) {
|
|
return {
|
|
op: "scene_save_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "\u5F53\u524D\u4E3A\u672A\u547D\u540D\u7684\u4E34\u65F6\u573A\u666F\u3002\u4E3A\u9632\u6B62\u7CFB\u7EDF\u6B7B\u9501\uFF0C\u8BF7\u4EBA\u7C7B\u7528\u6237\u5148\u6309 Ctrl+S \u624B\u52A8\u4FDD\u5B58\u4E00\u6B21\uFF0C\u4E4B\u540E\u5373\u53EF\u7531 AI \u81EA\u52A8\u4FDD\u5B58\u3002"
|
|
};
|
|
}
|
|
await Editor.Message.request("scene", "save-scene");
|
|
const result = { ok: true };
|
|
return { op: "scene_save_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_save_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
function parseFiniteVec3(v) {
|
|
if (!isRecord(v)) {
|
|
return null;
|
|
}
|
|
const x = v.x;
|
|
const y = v.y;
|
|
const z = v.z;
|
|
if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") {
|
|
return null;
|
|
}
|
|
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
|
return null;
|
|
}
|
|
return { x, y, z };
|
|
}
|
|
async function handleSetNodeTransform(id, payload) {
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareSetNodeTransform", [{ nid: payload.nid }]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_node_transform_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_set_node_transform_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isSetNodeActivePrepareOk(prepRaw)) {
|
|
return {
|
|
op: "scene_set_node_transform_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareSetNodeTransform returned unexpected shape"
|
|
};
|
|
}
|
|
try {
|
|
const inode = await Editor.Message.request("scene", "query-node", prepRaw.uuid);
|
|
const posR = inode.position ? readVec3FromPropertyDump(inode.position) : null;
|
|
const rotR = inode.rotation ? readVec3FromPropertyDump(inode.rotation) : null;
|
|
const scR = inode.scale ? readVec3FromPropertyDump(inode.scale) : null;
|
|
if (!posR || !rotR || !scR) {
|
|
return {
|
|
op: "scene_set_node_transform_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "query-node: could not read position, rotation, and scale as vec3"
|
|
};
|
|
}
|
|
const previous = {
|
|
position: cloneVec3(posR),
|
|
rotation: cloneVec3(rotR),
|
|
scale: cloneVec3(scR)
|
|
};
|
|
const toApply = [];
|
|
if (payload.position) {
|
|
toApply.push({ path: "position", vec: payload.position });
|
|
}
|
|
if (payload.rotationEuler) {
|
|
toApply.push({ path: "rotation", vec: payload.rotationEuler });
|
|
}
|
|
if (payload.scale) {
|
|
toApply.push({ path: "scale", vec: payload.scale });
|
|
}
|
|
const applied = [];
|
|
let anyChange = false;
|
|
for (const item of toApply) {
|
|
const rawProp = inode[item.path];
|
|
if (!rawProp || typeof rawProp !== "object") {
|
|
return {
|
|
op: "scene_set_node_transform_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `query-node: missing dump for ${item.path}`
|
|
};
|
|
}
|
|
const dump = cloneActiveDump(rawProp);
|
|
const before = readVec3FromPropertyDump(dump);
|
|
applyVec3ToPropertyDump(dump, item.vec);
|
|
const after = readVec3FromPropertyDump(dump);
|
|
if (before && after && !vec3NearlyEqual(before, after)) {
|
|
anyChange = true;
|
|
}
|
|
const ok = await Editor.Message.request(
|
|
"scene",
|
|
"set-property",
|
|
{
|
|
uuid: prepRaw.uuid,
|
|
path: item.path,
|
|
dump,
|
|
record: true
|
|
}
|
|
);
|
|
if (!ok) {
|
|
return {
|
|
op: "scene_set_node_transform_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "set-property returned false"
|
|
};
|
|
}
|
|
applied.push(item.path);
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
if (anyChange && applied.length > 0) {
|
|
mcpUndoStack.push({
|
|
kind: "set_node_transform",
|
|
uuid: prepRaw.uuid,
|
|
nid: payload.nid,
|
|
previous
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
}
|
|
const result = { ok: true, nid: payload.nid, applied };
|
|
return { op: "scene_set_node_transform_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_node_transform_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
async function handleSetComponentProperty(id, payload) {
|
|
if (typeof payload.value === "string" && payload.value.trim().replace(/\\/g, "/").toLowerCase().startsWith("assets/") && (payload.property.trim() === "spriteFrame" || payload.property.trim() === "font")) {
|
|
const pathAsset = await resolveAssetUuidFromPath(payload.value);
|
|
if (!pathAsset.ok) {
|
|
return {
|
|
op: "scene_set_component_property_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: pathAsset.message
|
|
};
|
|
}
|
|
payload = { ...payload, value: pathAsset.uuid };
|
|
}
|
|
let prepRaw;
|
|
try {
|
|
prepRaw = await executeSceneMethod("prepareSetComponentProperty", [payload]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_component_property_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (isSetNodeActivePrepareErr(prepRaw)) {
|
|
return { op: "scene_set_component_property_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: prepRaw.message };
|
|
}
|
|
if (!isPrepareSetComponentPropertyOk(prepRaw)) {
|
|
return {
|
|
op: "scene_set_component_property_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prepareSetComponentProperty returned unexpected shape"
|
|
};
|
|
}
|
|
const prep = prepRaw;
|
|
try {
|
|
let valueForDump = prep.value;
|
|
const propTrim = payload.property.trim();
|
|
const isAssetRefProp = prep.componentType === "Sprite" && propTrim === "spriteFrame" || prep.componentType === "Label" && propTrim === "font";
|
|
let targetAssetUuid = null;
|
|
if (prep.value && typeof prep.value === "object" && !Array.isArray(prep.value) && typeof prep.value.__uuid__ === "string") {
|
|
const uuidRaw = String(prep.value.__uuid__).trim();
|
|
if (prep.componentType === "Sprite" && propTrim === "spriteFrame") {
|
|
const resolved = await resolveSpriteFrameUuidFromAnyAssetUuid(uuidRaw);
|
|
if (!resolved.ok) {
|
|
return {
|
|
op: "scene_set_component_property_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: resolved.message
|
|
};
|
|
}
|
|
valueForDump = { __uuid__: resolved.uuid };
|
|
targetAssetUuid = resolved.uuid;
|
|
} else if (prep.componentType === "Label" && propTrim === "font") {
|
|
const resolved = await resolveFontUuid(uuidRaw);
|
|
if (!resolved.ok) {
|
|
return {
|
|
op: "scene_set_component_property_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: resolved.message
|
|
};
|
|
}
|
|
valueForDump = { __uuid__: resolved.uuid };
|
|
targetAssetUuid = resolved.uuid;
|
|
}
|
|
}
|
|
const inode = await Editor.Message.request("scene", "query-node", prep.uuid);
|
|
if (!inodeRecord(inode)) {
|
|
return {
|
|
op: "scene_set_component_property_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "query-node: expected object root"
|
|
};
|
|
}
|
|
let effectivePath = prep.path;
|
|
let rawDump = getDumpAtInodePath(inode, effectivePath);
|
|
if (!rawDump) {
|
|
const alt = resolveComponentPropertyPathFallback(
|
|
inode,
|
|
prep.path,
|
|
prep.componentType,
|
|
payload.property.trim()
|
|
);
|
|
if (alt) {
|
|
effectivePath = alt;
|
|
rawDump = getDumpAtInodePath(inode, effectivePath);
|
|
}
|
|
}
|
|
if (!rawDump) {
|
|
if (isAssetRefProp && targetAssetUuid) {
|
|
let directErr = null;
|
|
let directRaw = await executeSceneMethod("setComponentAssetRefDirect", [
|
|
prep.uuid,
|
|
prep.componentType,
|
|
propTrim,
|
|
targetAssetUuid
|
|
]);
|
|
if (inodeRecord(directRaw) && directRaw.ok === false && typeof directRaw.message === "string" && (directRaw.message.includes("assetManager.assets.get is unavailable") || directRaw.message.includes("assetManager.loadAny is unavailable"))) {
|
|
directRaw = await executeSceneMethod("setComponentAssetRefFromScene", [
|
|
prep.uuid,
|
|
prep.componentType,
|
|
propTrim,
|
|
targetAssetUuid
|
|
]);
|
|
}
|
|
if (inodeRecord(directRaw) && directRaw.ok === true) {
|
|
const changedDirect = directRaw.changed === true;
|
|
if (changedDirect) {
|
|
mcpUndoStack.push({
|
|
kind: "set_component_asset_direct",
|
|
uuid: prep.uuid,
|
|
nid: payload.nid,
|
|
componentType: prep.componentType,
|
|
property: propTrim,
|
|
previousAssetUuid: typeof directRaw.beforeUuid === "string" ? directRaw.beforeUuid : null
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
const result2 = {
|
|
ok: true,
|
|
nid: payload.nid,
|
|
componentIndex: payload.componentIndex,
|
|
componentType: prep.componentType,
|
|
property: propTrim,
|
|
changed: changedDirect,
|
|
effectivePath: "[runtime-direct]",
|
|
resolvedAssetUuid: targetAssetUuid,
|
|
beforeValue: toDebugScalar(directRaw.beforeUuid),
|
|
afterValue: toDebugScalar(directRaw.afterUuid)
|
|
};
|
|
return { op: "scene_set_component_property_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result: result2 };
|
|
}
|
|
if (inodeRecord(directRaw) && directRaw.ok === false && typeof directRaw.message === "string") {
|
|
directErr = directRaw.message;
|
|
}
|
|
if (directErr) {
|
|
return { op: "scene_set_component_property_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: directErr };
|
|
}
|
|
}
|
|
if (!isAssetRefProp) {
|
|
const directRaw = await executeSceneMethod("setComponentScalarPropertyDirect", [
|
|
prep.uuid,
|
|
payload.componentIndex,
|
|
propTrim,
|
|
valueForDump
|
|
]);
|
|
if (inodeRecord(directRaw) && directRaw.ok === true) {
|
|
const changedDirect = directRaw.changed === true;
|
|
if (changedDirect) {
|
|
mcpUndoStack.push({
|
|
kind: "set_component_scalar_direct",
|
|
uuid: prep.uuid,
|
|
nid: payload.nid,
|
|
componentIndex: payload.componentIndex,
|
|
property: propTrim,
|
|
previousValue: directRaw.beforeValue
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
const result2 = {
|
|
ok: true,
|
|
nid: payload.nid,
|
|
componentIndex: payload.componentIndex,
|
|
componentType: prep.componentType,
|
|
property: propTrim,
|
|
changed: changedDirect,
|
|
effectivePath: "[runtime-direct-value]",
|
|
beforeValue: toDebugScalar(directRaw.beforeValue),
|
|
afterValue: toDebugScalar(directRaw.afterValue)
|
|
};
|
|
return { op: "scene_set_component_property_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result: result2 };
|
|
}
|
|
if (inodeRecord(directRaw) && directRaw.ok === false && typeof directRaw.message === "string") {
|
|
return {
|
|
op: "scene_set_component_property_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: directRaw.message
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
// Final fallback: direct runtime component assignment (requires asset to be loaded).
|
|
// This path is used when query-node dump is unavailable in some Creator builds.
|
|
op: "scene_set_component_property_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `query-node: no dump at path ${prep.path} (fallback unresolved)`
|
|
};
|
|
}
|
|
const previousDump = cloneActiveDump(rawDump);
|
|
const beforeValue = readComparableValueFromPropertyDump(rawDump);
|
|
const dump = cloneActiveDump(rawDump);
|
|
applyScalarToPropertyDump(dump, valueForDump);
|
|
const afterValue = readComparableValueFromPropertyDump(dump);
|
|
const changed = !valuesEqualForChange(beforeValue, afterValue);
|
|
const ok = await Editor.Message.request(
|
|
"scene",
|
|
"set-property",
|
|
{
|
|
uuid: prep.uuid,
|
|
path: effectivePath,
|
|
dump,
|
|
record: true
|
|
}
|
|
);
|
|
if (!ok) {
|
|
if (isAssetRefProp && targetAssetUuid) {
|
|
let directErr = null;
|
|
let directRaw = await executeSceneMethod("setComponentAssetRefDirect", [
|
|
prep.uuid,
|
|
prep.componentType,
|
|
propTrim,
|
|
targetAssetUuid
|
|
]);
|
|
if (inodeRecord(directRaw) && directRaw.ok === false && typeof directRaw.message === "string" && (directRaw.message.includes("assetManager.assets.get is unavailable") || directRaw.message.includes("assetManager.loadAny is unavailable"))) {
|
|
directRaw = await executeSceneMethod("setComponentAssetRefFromScene", [
|
|
prep.uuid,
|
|
prep.componentType,
|
|
propTrim,
|
|
targetAssetUuid
|
|
]);
|
|
}
|
|
if (inodeRecord(directRaw) && directRaw.ok === true) {
|
|
const changedDirect = directRaw.changed === true;
|
|
if (changedDirect) {
|
|
mcpUndoStack.push({
|
|
kind: "set_component_asset_direct",
|
|
uuid: prep.uuid,
|
|
nid: payload.nid,
|
|
componentType: prep.componentType,
|
|
property: propTrim,
|
|
previousAssetUuid: typeof directRaw.beforeUuid === "string" ? directRaw.beforeUuid : null
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
const result2 = {
|
|
ok: true,
|
|
nid: payload.nid,
|
|
componentIndex: payload.componentIndex,
|
|
componentType: prep.componentType,
|
|
property: payload.property.trim(),
|
|
changed: changedDirect,
|
|
effectivePath: "[runtime-direct]",
|
|
resolvedAssetUuid: targetAssetUuid,
|
|
beforeValue: toDebugScalar(directRaw.beforeUuid),
|
|
afterValue: toDebugScalar(directRaw.afterUuid)
|
|
};
|
|
return { op: "scene_set_component_property_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result: result2 };
|
|
}
|
|
if (inodeRecord(directRaw) && directRaw.ok === false && typeof directRaw.message === "string") {
|
|
directErr = directRaw.message;
|
|
}
|
|
if (directErr) {
|
|
return { op: "scene_set_component_property_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: directErr };
|
|
}
|
|
}
|
|
return {
|
|
op: "scene_set_component_property_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "set-property returned false"
|
|
};
|
|
}
|
|
if (isAssetRefProp && targetAssetUuid) {
|
|
try {
|
|
const afterRaw = await executeSceneMethod("getComponentAssetRefUuid", [
|
|
prep.uuid,
|
|
prep.componentType,
|
|
propTrim
|
|
]);
|
|
if (inodeRecord(afterRaw) && afterRaw.ok === true) {
|
|
const actualUuid = typeof afterRaw.uuid === "string" ? afterRaw.uuid : null;
|
|
if (actualUuid !== targetAssetUuid) {
|
|
return {
|
|
op: "scene_set_component_property_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `asset write did not apply to runtime component (target=${targetAssetUuid}, actual=${String(actualUuid)}). Please provide a valid SpriteFrame/Font UUID (not texture/other asset UUID).`
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
if (changed) {
|
|
mcpUndoStack.push({
|
|
kind: "set_component_property",
|
|
uuid: prep.uuid,
|
|
nid: payload.nid,
|
|
path: effectivePath,
|
|
previousDump
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
nid: payload.nid,
|
|
componentIndex: payload.componentIndex,
|
|
componentType: prep.componentType,
|
|
property: payload.property.trim(),
|
|
changed,
|
|
effectivePath,
|
|
resolvedAssetUuid: valueForDump && typeof valueForDump === "object" && !Array.isArray(valueForDump) && typeof valueForDump.__uuid__ === "string" ? String(valueForDump.__uuid__) : void 0,
|
|
beforeValue: toDebugScalar(beforeValue),
|
|
afterValue: toDebugScalar(afterValue)
|
|
};
|
|
return { op: "scene_set_component_property_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_set_component_property_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
function uiSpecStats(nodes) {
|
|
let count = 0;
|
|
let depth = 0;
|
|
const pending = [{ items: nodes, level: 1 }];
|
|
while (pending.length > 0) {
|
|
const current = pending.pop();
|
|
const { items, level } = current;
|
|
depth = Math.max(depth, level);
|
|
for (const item of items) {
|
|
count += item.kind === "button" ? 2 : 1;
|
|
if (item.kind === "button") {
|
|
depth = Math.max(depth, level + 1);
|
|
}
|
|
if (item.children && item.children.length > 0) {
|
|
pending.push({ items: item.children, level: level + 1 });
|
|
}
|
|
}
|
|
}
|
|
return { count, depth };
|
|
}
|
|
function requireWsResult(response, expectedOp) {
|
|
if (response.op !== expectedOp) {
|
|
const message = "message" in response && typeof response.message === "string" ? response.message : `unexpected response: ${response.op}`;
|
|
throw new Error(message);
|
|
}
|
|
return response.result;
|
|
}
|
|
async function removeUiTransactionCreatedNodes(entries, sceneUuid) {
|
|
const createEntries = entries.filter(
|
|
(entry) => entry.kind === "create_node"
|
|
);
|
|
const createdUuids = createEntries.map((entry) => entry.uuid);
|
|
if (createEntries.length === 0) {
|
|
return {
|
|
ok: false,
|
|
message: "UI transaction contains no created nodes to remove.",
|
|
residualEntries: [],
|
|
terminal: true
|
|
};
|
|
}
|
|
try {
|
|
const result = await executeSceneMethod("removeCreatedNodesForMcpUndo", [
|
|
createdUuids,
|
|
sceneUuid
|
|
]);
|
|
if (!(inodeRecord(result) && result.ok === true && Array.isArray(result.removed))) {
|
|
const detail = inodeRecord(result) && typeof result.message === "string" ? result.message : "unexpected response";
|
|
const remaining = inodeRecord(result) && Array.isArray(result.remaining) ? new Set(result.remaining.filter((value) => typeof value === "string")) : new Set(createdUuids);
|
|
return {
|
|
ok: false,
|
|
message: detail,
|
|
residualEntries: createEntries.filter((entry) => remaining.has(entry.uuid)),
|
|
terminal: inodeRecord(result) && result.terminal === true
|
|
};
|
|
}
|
|
await sceneCommitUndoSnapshot();
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
residualEntries: createEntries,
|
|
terminal: false
|
|
};
|
|
}
|
|
}
|
|
async function rollbackUiTransaction(startIndex, sceneUuid) {
|
|
const entries = mcpUndoStack.splice(startIndex);
|
|
if (entries.length === 0) {
|
|
return [];
|
|
}
|
|
const removed = await removeUiTransactionCreatedNodes(entries, sceneUuid);
|
|
if (!removed.ok && !removed.terminal && removed.residualEntries.length > 0) {
|
|
const residualRoot = removed.residualEntries.find((entry) => entry.kind === "create_node");
|
|
mcpUndoStack.push({
|
|
kind: "ui_transaction",
|
|
nid: residualRoot?.nid ?? 0,
|
|
sceneUuid,
|
|
entries: removed.residualEntries
|
|
});
|
|
}
|
|
return removed.ok ? [] : [removed.message];
|
|
}
|
|
async function addUiComponent(nid, componentType) {
|
|
const response = await handleAddComponent(`ui-add-${nid}-${componentType}`, { nid, componentType });
|
|
const result = requireWsResult(response, "scene_add_component_ok");
|
|
return result.componentIndex;
|
|
}
|
|
async function findUiComponentIndex(nid, componentType) {
|
|
const inspected = await executeSceneMethod("inspectNode", [{ nid }]);
|
|
if (!inspected || inspected.ok !== true) {
|
|
const message = inspected && "message" in inspected ? inspected.message : "inspectNode returned no result";
|
|
throw new Error(`Unable to inspect UI node ${nid}: ${message}`);
|
|
}
|
|
const requestedType = normalizeComponentTypeName(componentType);
|
|
const index = inspected.data.components.findIndex(
|
|
(existingType) => normalizeComponentTypeName(existingType) === requestedType
|
|
);
|
|
return index >= 0 ? index : null;
|
|
}
|
|
async function ensureUiComponent(nid, componentType) {
|
|
const existingIndex = await findUiComponentIndex(nid, componentType);
|
|
if (existingIndex !== null) {
|
|
return existingIndex;
|
|
}
|
|
try {
|
|
return await addUiComponent(nid, componentType);
|
|
} catch (error) {
|
|
const racedIndex = await findUiComponentIndex(nid, componentType);
|
|
if (racedIndex !== null) {
|
|
return racedIndex;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
async function setUiComponentProperty(nid, componentIndex, property, value) {
|
|
const response = await handleSetComponentProperty(`ui-prop-${nid}-${componentIndex}-${property}`, {
|
|
nid,
|
|
componentIndex,
|
|
property,
|
|
value
|
|
});
|
|
requireWsResult(response, "scene_set_component_property_ok");
|
|
}
|
|
async function applyUiTransform(nid, node) {
|
|
const uiTransform = await ensureUiComponent(nid, "UITransform");
|
|
const defaults = node.kind === "label" ? { width: 220, height: 48 } : node.kind === "button" ? { width: 180, height: 56 } : node.kind === "container" ? { width: 320, height: 180 } : { width: 100, height: 100 };
|
|
const size = node.size ?? defaults;
|
|
await setUiComponentProperty(nid, uiTransform, "width", size.width);
|
|
await setUiComponentProperty(nid, uiTransform, "height", size.height);
|
|
return uiTransform;
|
|
}
|
|
async function applyUiWidget(nid, widget) {
|
|
const componentIndex = await ensureUiComponent(nid, "Widget");
|
|
const alignments = [
|
|
{ key: "top", enabledProperty: "isAlignTop", valueProperty: "top" },
|
|
{ key: "bottom", enabledProperty: "isAlignBottom", valueProperty: "bottom" },
|
|
{ key: "left", enabledProperty: "isAlignLeft", valueProperty: "left" },
|
|
{ key: "right", enabledProperty: "isAlignRight", valueProperty: "right" },
|
|
{
|
|
key: "horizontalCenter",
|
|
enabledProperty: "isAlignHorizontalCenter",
|
|
valueProperty: "horizontalCenter"
|
|
},
|
|
{
|
|
key: "verticalCenter",
|
|
enabledProperty: "isAlignVerticalCenter",
|
|
valueProperty: "verticalCenter"
|
|
}
|
|
];
|
|
for (const alignment of alignments) {
|
|
const value = widget[alignment.key];
|
|
if (typeof value === "number") {
|
|
await setUiComponentProperty(nid, componentIndex, alignment.enabledProperty, true);
|
|
await setUiComponentProperty(nid, componentIndex, alignment.valueProperty, value);
|
|
}
|
|
}
|
|
}
|
|
async function applyUiLayout(nid, layout) {
|
|
const componentIndex = await ensureUiComponent(nid, "Layout");
|
|
await setUiComponentProperty(nid, componentIndex, "type", layout.type.toUpperCase());
|
|
await setUiComponentProperty(
|
|
nid,
|
|
componentIndex,
|
|
"resizeMode",
|
|
(layout.resizeMode ?? "none").toUpperCase()
|
|
);
|
|
const requestedConstraint = layout.constraint ?? (layout.type === "grid" && layout.constraintNum ? "fixed-column" : void 0);
|
|
if (requestedConstraint) {
|
|
const constraint = requestedConstraint === "fixed-column" ? "FIXED_COL" : requestedConstraint === "fixed-row" ? "FIXED_ROW" : "NONE";
|
|
await setUiComponentProperty(nid, componentIndex, "constraint", constraint);
|
|
}
|
|
const scalarProperties = [
|
|
"spacingX",
|
|
"spacingY",
|
|
"paddingLeft",
|
|
"paddingRight",
|
|
"paddingTop",
|
|
"paddingBottom",
|
|
"constraintNum"
|
|
];
|
|
for (const property of scalarProperties) {
|
|
const value = layout[property];
|
|
if (typeof value === "number") {
|
|
await setUiComponentProperty(nid, componentIndex, property, value);
|
|
}
|
|
}
|
|
if (layout.cellSize) {
|
|
await setUiComponentProperty(nid, componentIndex, "cellSize", layout.cellSize);
|
|
}
|
|
}
|
|
async function managedShapeSpriteFrameUuid(shape) {
|
|
const asset = ensureManagedShapeAsset(Editor.Project.path, shape);
|
|
const resolved = await resolveAssetUuidFromPath(asset.relativePath, asset.created);
|
|
if (!resolved.ok) {
|
|
throw new Error(resolved.message);
|
|
}
|
|
const existingSpriteFrame = await resolveSpriteFrameUuidFromAnyAssetUuid(resolved.uuid);
|
|
if (existingSpriteFrame.ok) {
|
|
return existingSpriteFrame.uuid;
|
|
}
|
|
const normalized = normalizeProjectAssetRelativePath(asset.relativePath);
|
|
if (!normalized.ok) {
|
|
throw new Error(normalized.message);
|
|
}
|
|
const rawMeta = await Editor.Message.request(
|
|
"asset-db",
|
|
"query-asset-meta",
|
|
normalized.dbUrl
|
|
);
|
|
const prepared = prepareManagedShapeSpriteFrameMeta(rawMeta);
|
|
if (prepared.changed) {
|
|
await Editor.Message.request(
|
|
"asset-db",
|
|
"save-asset-meta",
|
|
normalized.dbUrl,
|
|
JSON.stringify(prepared.meta)
|
|
);
|
|
}
|
|
await Editor.Message.request("asset-db", "reimport-asset", normalized.dbUrl);
|
|
let lastMessage = existingSpriteFrame.message;
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
const spriteFrame = await resolveSpriteFrameUuidFromAnyAssetUuid(resolved.uuid);
|
|
if (spriteFrame.ok) {
|
|
return spriteFrame.uuid;
|
|
}
|
|
lastMessage = spriteFrame.message;
|
|
await sleep(100);
|
|
}
|
|
throw new Error(`Managed shape SpriteFrame import did not finish within 5 seconds: ${lastMessage}`);
|
|
}
|
|
async function applyUiSprite(nid, options) {
|
|
const componentIndex = await ensureUiComponent(nid, "Sprite");
|
|
await setUiComponentProperty(nid, componentIndex, "sizeMode", "CUSTOM");
|
|
let spriteFrameValue;
|
|
if (options.managedShape) {
|
|
spriteFrameValue = await managedShapeSpriteFrameUuid(options.managedShape);
|
|
} else if (options.assetPath) {
|
|
spriteFrameValue = options.assetPath;
|
|
}
|
|
if (spriteFrameValue) {
|
|
await setUiComponentProperty(nid, componentIndex, "spriteFrame", spriteFrameValue);
|
|
}
|
|
if (options.color) {
|
|
await setUiComponentProperty(nid, componentIndex, "color", options.color);
|
|
}
|
|
if (options.nineSlice) {
|
|
await setUiComponentProperty(nid, componentIndex, "type", "SLICED");
|
|
}
|
|
}
|
|
async function applyUiLabel(nid, node) {
|
|
const componentIndex = await ensureUiComponent(nid, "Label");
|
|
await setUiComponentProperty(nid, componentIndex, "overflow", "CLAMP");
|
|
await setUiComponentProperty(nid, componentIndex, "string", node.text ?? node.buttonLabel ?? "Label");
|
|
await setUiComponentProperty(nid, componentIndex, "fontSize", node.fontSize ?? 28);
|
|
await setUiComponentProperty(nid, componentIndex, "lineHeight", node.lineHeight ?? node.fontSize ?? 32);
|
|
await setUiComponentProperty(
|
|
nid,
|
|
componentIndex,
|
|
"horizontalAlign",
|
|
(node.horizontalAlign ?? "center").toUpperCase()
|
|
);
|
|
await setUiComponentProperty(
|
|
nid,
|
|
componentIndex,
|
|
"verticalAlign",
|
|
(node.verticalAlign ?? "center").toUpperCase()
|
|
);
|
|
if (node.color) {
|
|
await setUiComponentProperty(nid, componentIndex, "color", node.color);
|
|
}
|
|
await applyUiTransform(nid, node);
|
|
}
|
|
async function createUiNodeRecursive(parentNid, node, created, verification, sequence) {
|
|
sequence.value += 1;
|
|
const defaultName = `${node.kind[0].toUpperCase()}${node.kind.slice(1)}_${sequence.value}`;
|
|
const name = node.name?.trim() || defaultName;
|
|
const createResponse = await handleCreateNode(`ui-create-${sequence.value}`, { parentNid, name });
|
|
const createdNode = requireWsResult(createResponse, "scene_create_node_ok");
|
|
const nid = createdNode.nid;
|
|
created.push({ ...node.id ? { id: node.id } : {}, nid, name: createdNode.name, kind: node.kind });
|
|
verification.push({ nid, expected: { ...node, name: createdNode.name } });
|
|
await applyUiTransform(nid, node);
|
|
if (node.position) {
|
|
const transformResponse = await handleSetNodeTransform(`ui-transform-${nid}`, {
|
|
nid,
|
|
position: node.position
|
|
});
|
|
requireWsResult(transformResponse, "scene_set_node_transform_ok");
|
|
}
|
|
if (node.active === false) {
|
|
const activeResponse = await handleSetNodeActive(`ui-active-${nid}`, { nid, active: false });
|
|
requireWsResult(activeResponse, "scene_set_node_active_ok");
|
|
}
|
|
if (node.kind === "shape") {
|
|
await applyUiSprite(nid, {
|
|
managedShape: node.shape ?? "rectangle",
|
|
color: node.color ?? "#FFFFFFFF"
|
|
});
|
|
} else if (node.kind === "label") {
|
|
await applyUiLabel(nid, node);
|
|
} else if (node.kind === "sprite") {
|
|
if (!node.assetPath) {
|
|
throw new Error(`Sprite node "${name}" requires assetPath.`);
|
|
}
|
|
await applyUiSprite(nid, {
|
|
assetPath: node.assetPath,
|
|
color: node.color,
|
|
nineSlice: node.nineSlice
|
|
});
|
|
} else if (node.kind === "button") {
|
|
await applyUiSprite(nid, {
|
|
managedShape: "rounded-rectangle",
|
|
color: node.color ?? "#3478F6FF"
|
|
});
|
|
const buttonIndex = await ensureUiComponent(nid, "Button");
|
|
await setUiComponentProperty(nid, buttonIndex, "interactable", node.interactable !== false);
|
|
await setUiComponentProperty(nid, buttonIndex, "transition", "SCALE");
|
|
await setUiComponentProperty(nid, buttonIndex, "zoomScale", 0.92);
|
|
const labelNode = {
|
|
kind: "label",
|
|
name: `${name}_Label`,
|
|
text: node.buttonLabel ?? node.text ?? "Button",
|
|
fontSize: node.fontSize ?? 24,
|
|
size: node.size ?? { width: 180, height: 56 },
|
|
horizontalAlign: "center",
|
|
verticalAlign: "center"
|
|
};
|
|
await createUiNodeRecursive(nid, labelNode, created, verification, sequence);
|
|
}
|
|
if (node.widget) {
|
|
await applyUiWidget(nid, node.widget);
|
|
}
|
|
for (const child of node.children ?? []) {
|
|
await createUiNodeRecursive(nid, child, created, verification, sequence);
|
|
}
|
|
if (node.layout) {
|
|
await applyUiLayout(nid, node.layout);
|
|
}
|
|
return nid;
|
|
}
|
|
async function handleApplyUiSpec(id, payload) {
|
|
const stats = uiSpecStats(payload.nodes);
|
|
if (stats.count < 1 || stats.count > 64 || stats.depth > 12) {
|
|
return {
|
|
op: "scene_apply_ui_spec_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `UI spec exceeds limits (nodes=${stats.count}/64, depth=${stats.depth}/12).`
|
|
};
|
|
}
|
|
const startIndex = mcpUndoStack.length;
|
|
let transactionSceneUuid = "";
|
|
const created = [];
|
|
const verification = [];
|
|
try {
|
|
const sceneMeta = await executeSceneMethod("getCurrentSceneMeta", []);
|
|
if (!inodeRecord(sceneMeta) || sceneMeta.ok !== true || sceneMeta.hasScene !== true || typeof sceneMeta.sceneAssetUuid !== "string" || sceneMeta.sceneAssetUuid.length === 0) {
|
|
throw new Error("Unable to bind the UI transaction to the active scene.");
|
|
}
|
|
transactionSceneUuid = sceneMeta.sceneAssetUuid;
|
|
const sequence = { value: 0 };
|
|
for (const node of payload.nodes) {
|
|
await createUiNodeRecursive(payload.parentNid, node, created, verification, sequence);
|
|
}
|
|
const settleAndVerify = async () => {
|
|
const settled = await executeSceneMethod("settleUiNodes", [
|
|
verification.map((entry) => entry.nid)
|
|
]);
|
|
if (!settled || settled.ok !== true) {
|
|
throw new Error(settled?.message ?? "Runtime UI settle returned no result.");
|
|
}
|
|
for (const entry of verification) {
|
|
const verified = await executeSceneMethod("verifyUiNode", [entry]);
|
|
if (!verified || verified.ok !== true) {
|
|
throw new Error(
|
|
verified?.message ?? `Runtime verification failed for nid ${entry.nid} (${entry.expected.name}).`
|
|
);
|
|
}
|
|
}
|
|
};
|
|
await settleAndVerify();
|
|
await sleep(50);
|
|
await settleAndVerify();
|
|
if (payload.save !== false) {
|
|
const saveResponse = await handleSaveScene(`${id}:save`);
|
|
requireWsResult(saveResponse, "scene_save_ok");
|
|
}
|
|
const entries = mcpUndoStack.splice(startIndex);
|
|
const transactionNid = created[0]?.nid ?? payload.parentNid;
|
|
mcpUndoStack.push({
|
|
kind: "ui_transaction",
|
|
nid: transactionNid,
|
|
sceneUuid: transactionSceneUuid,
|
|
entries
|
|
});
|
|
while (mcpUndoStack.length > MCP_UNDO_MAX) {
|
|
mcpUndoStack.shift();
|
|
}
|
|
const diagnostics = [
|
|
{
|
|
level: "info",
|
|
code: "runtime_verified",
|
|
message: `${created.length} UI node(s) and their final components, settled sizes, colors, text, layout, and widget properties were read back from the Creator Scene runtime.`
|
|
}
|
|
];
|
|
const layoutOwnedSizes = verification.filter(
|
|
(entry) => entry.expected.layout?.resizeMode === "container"
|
|
).length;
|
|
if (layoutOwnedSizes > 0) {
|
|
diagnostics.push({
|
|
level: "info",
|
|
code: "layout_owns_container_size",
|
|
message: `${layoutOwnedSizes} container size(s) were settled from children, padding, and spacing because Layout.resizeMode is container.`
|
|
});
|
|
}
|
|
const result = {
|
|
status: "ok",
|
|
changed: created.length > 0,
|
|
verified: true,
|
|
undoAvailable: entries.length > 0,
|
|
diagnostics,
|
|
nextAction: payload.save === false ? "Call cocos_save_scene when the layout is ready to persist." : null,
|
|
parentNid: payload.parentNid,
|
|
created
|
|
};
|
|
return { op: "scene_apply_ui_spec_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (error) {
|
|
const rollbackStepErrors = await rollbackUiTransaction(startIndex, transactionSceneUuid);
|
|
const residualNodes = [];
|
|
for (const receipt of created) {
|
|
try {
|
|
const inspected = await executeSceneMethod("inspectNode", [{ nid: receipt.nid }]);
|
|
if (inspected?.ok === true) {
|
|
residualNodes.push(`${receipt.name} (nid ${receipt.nid})`);
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
const rollbackErrors = [
|
|
...mcpUndoStack.length > startIndex ? [`${mcpUndoStack.length - startIndex} undo entry(s) remain after rollback.`] : [],
|
|
...residualNodes.length > 0 ? [`Residual UI nodes: ${residualNodes.join(", ")}`] : []
|
|
];
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const diagnostics = [
|
|
{ level: "error", code: "ui_apply_failed", message },
|
|
...rollbackStepErrors.map((rollbackWarning) => ({
|
|
level: rollbackErrors.length === 0 ? "warning" : "error",
|
|
code: "rollback_step_failed",
|
|
message: rollbackWarning
|
|
})),
|
|
...rollbackErrors.length === 0 ? [{ level: "info", code: "rollback_complete", message: "All partial scene changes were rolled back." }] : rollbackErrors.map((rollbackError) => ({
|
|
level: "error",
|
|
code: "rollback_failed",
|
|
message: rollbackError
|
|
}))
|
|
];
|
|
return {
|
|
op: "scene_apply_ui_spec_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: rollbackErrors.length === 0 ? message : `${message}; rollback: ${rollbackErrors.join("; ")}`,
|
|
diagnostics
|
|
};
|
|
}
|
|
}
|
|
async function handleGetCurrentSceneMeta(id) {
|
|
let raw;
|
|
try {
|
|
raw = await executeSceneMethod("getCurrentSceneMeta", []);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "scene_get_current_meta_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (!isRecord(raw) || raw.ok !== true) {
|
|
return {
|
|
op: "scene_get_current_meta_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "getCurrentSceneMeta returned unexpected shape"
|
|
};
|
|
}
|
|
if (raw.hasScene === false) {
|
|
const result2 = { ok: true, hasScene: false };
|
|
return { op: "scene_get_current_meta_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result: result2 };
|
|
}
|
|
if (raw.hasScene !== true || typeof raw.sceneName !== "string" || typeof raw.sceneAssetUuid !== "string") {
|
|
return {
|
|
op: "scene_get_current_meta_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "getCurrentSceneMeta missing sceneName or sceneAssetUuid"
|
|
};
|
|
}
|
|
let assetPath;
|
|
try {
|
|
const info = await Editor.Message.request(
|
|
"asset-db",
|
|
"query-asset-info",
|
|
raw.sceneAssetUuid
|
|
);
|
|
if (info && typeof info.file === "string" && info.file.length > 0) {
|
|
const rel = path3.relative(Editor.Project.path, info.file).replace(/\\/g, "/");
|
|
if (!rel.startsWith("..") && !path3.isAbsolute(rel)) {
|
|
assetPath = rel;
|
|
}
|
|
}
|
|
} catch {
|
|
}
|
|
const result = {
|
|
ok: true,
|
|
hasScene: true,
|
|
sceneName: raw.sceneName,
|
|
...assetPath !== void 0 ? { assetPath } : {}
|
|
};
|
|
return { op: "scene_get_current_meta_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
}
|
|
async function forwardToScene(id, method, args) {
|
|
try {
|
|
const raw = await executeSceneMethod(method, args);
|
|
if (method === "getHierarchy") {
|
|
return {
|
|
op: "scene_hierarchy_ok",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
result: typeof raw === "string" ? raw : String(raw)
|
|
};
|
|
}
|
|
if (method === "searchNodes") {
|
|
return {
|
|
op: "scene_search_ok",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
result: typeof raw === "string" ? raw : String(raw)
|
|
};
|
|
}
|
|
return {
|
|
op: "scene_inspect_ok",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
result: raw
|
|
};
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
if (method === "getHierarchy") {
|
|
return { op: "scene_hierarchy_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
if (method === "searchNodes") {
|
|
return { op: "scene_search_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
return { op: "scene_inspect_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
function safeRelativeProjectPath(raw, fallback) {
|
|
const s = typeof raw === "string" && raw.trim() ? raw.trim().replace(/\\/g, "/") : fallback;
|
|
if (s.includes("..") || path3.isAbsolute(s)) {
|
|
return fallback;
|
|
}
|
|
return s;
|
|
}
|
|
function normalizeProjectAssetRelativePath(raw, options = {}) {
|
|
if (typeof raw !== "string") {
|
|
return { ok: false, message: "assetPath must be a string." };
|
|
}
|
|
const value = raw.trim().replace(/\\/g, "/").replace(/^db:\/\//, "");
|
|
if (!value || value.length > 512 || value.includes("..") || path3.isAbsolute(value)) {
|
|
return { ok: false, message: "assetPath must be a project-relative assets/... path without .. segments." };
|
|
}
|
|
const rel = value.replace(/^\/+/, "");
|
|
if (!rel.toLowerCase().startsWith("assets/")) {
|
|
return { ok: false, message: "assetPath must start with assets/." };
|
|
}
|
|
const absolute = path3.resolve(Editor.Project.path, rel);
|
|
const assetsRoot = path3.resolve(Editor.Project.path, "assets");
|
|
const fromAssets = path3.relative(assetsRoot, absolute);
|
|
if (fromAssets.startsWith("..") || path3.isAbsolute(fromAssets)) {
|
|
return { ok: false, message: "assetPath must resolve inside the project assets directory." };
|
|
}
|
|
if (options.mustExist !== false && !fs3.existsSync(absolute)) {
|
|
return { ok: false, message: `Asset file does not exist: ${rel}` };
|
|
}
|
|
return { ok: true, rel, dbUrl: `db://${rel}` };
|
|
}
|
|
async function resolveAssetUuidFromPath(assetPath, refresh = false) {
|
|
const normalized = normalizeProjectAssetRelativePath(assetPath);
|
|
if (!normalized.ok) {
|
|
return normalized;
|
|
}
|
|
if (refresh) {
|
|
try {
|
|
await Editor.Message.request("asset-db", "refresh-asset", normalized.dbUrl);
|
|
} catch {
|
|
}
|
|
}
|
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
try {
|
|
const info = await Editor.Message.request(
|
|
"asset-db",
|
|
"query-asset-info",
|
|
normalized.dbUrl
|
|
);
|
|
if (info && info.invalid !== true && typeof info.uuid === "string" && info.uuid.length > 0) {
|
|
return { ok: true, uuid: info.uuid, rel: normalized.rel };
|
|
}
|
|
} catch {
|
|
}
|
|
await sleep(100);
|
|
}
|
|
return { ok: false, message: `Asset DB did not import ${normalized.rel} within 3 seconds.` };
|
|
}
|
|
function normalizePrefabAssetRelativePath(raw) {
|
|
if (typeof raw !== "string") {
|
|
return { ok: false, message: "prefabPath must be a string." };
|
|
}
|
|
const t = raw.trim().replace(/\\/g, "/");
|
|
if (!t || t.length > 512) {
|
|
return { ok: false, message: "prefabPath is empty or too long (max 512)." };
|
|
}
|
|
if (t.includes("..")) {
|
|
return { ok: false, message: "prefabPath must not contain .." };
|
|
}
|
|
if (path3.isAbsolute(t)) {
|
|
return { ok: false, message: "prefabPath must be relative to project root (e.g. assets/Foo.prefab)." };
|
|
}
|
|
const relForward = t.replace(/^\/+/, "");
|
|
if (!relForward.toLowerCase().startsWith("assets/")) {
|
|
return { ok: false, message: "prefabPath must start with assets/" };
|
|
}
|
|
if (!relForward.toLowerCase().endsWith(".prefab")) {
|
|
return { ok: false, message: "prefabPath must end with .prefab" };
|
|
}
|
|
const absFile = path3.resolve(Editor.Project.path, relForward);
|
|
const assetsRoot = path3.resolve(Editor.Project.path, "assets");
|
|
const relFromAssets = path3.relative(assetsRoot, absFile);
|
|
if (relFromAssets.startsWith("..") || path3.isAbsolute(relFromAssets)) {
|
|
return { ok: false, message: "prefabPath must resolve inside assets/" };
|
|
}
|
|
if (!fs3.existsSync(absFile)) {
|
|
return { ok: false, message: "Prefab file does not exist on disk at the given path." };
|
|
}
|
|
try {
|
|
const st = fs3.statSync(absFile);
|
|
if (!st.isFile()) {
|
|
return { ok: false, message: "prefabPath must point to a file." };
|
|
}
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { ok: false, message };
|
|
}
|
|
return { ok: true, rel: relForward };
|
|
}
|
|
function extractSpriteFrameUuidFromSubAssets(subAssets) {
|
|
if (!subAssets || typeof subAssets !== "object" || Array.isArray(subAssets)) {
|
|
return null;
|
|
}
|
|
for (const v of Object.values(subAssets)) {
|
|
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
continue;
|
|
}
|
|
const item = v;
|
|
const ty = typeof item.type === "string" ? item.type.toLowerCase() : "";
|
|
const im = typeof item.importer === "string" ? item.importer.toLowerCase() : "";
|
|
const uuid = typeof item.uuid === "string" ? item.uuid : "";
|
|
if (uuid && (ty.includes("spriteframe") || im.includes("sprite-frame") || im.includes("spriteframe"))) {
|
|
return uuid;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function parseAssetMeta(rawMeta) {
|
|
if (typeof rawMeta === "string") {
|
|
try {
|
|
const parsed = JSON.parse(rawMeta);
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return rawMeta && typeof rawMeta === "object" && !Array.isArray(rawMeta) ? rawMeta : null;
|
|
}
|
|
async function resolveSpriteFrameUuidFromAnyAssetUuid(uuid) {
|
|
try {
|
|
const info = await Editor.Message.request("asset-db", "query-asset-info", uuid);
|
|
if (!info || typeof info !== "object") {
|
|
return { ok: false, message: "\u8D44\u6E90 UUID \u65E0\u6548\u6216\u672A\u5BFC\u5165\u5B8C\u6210\u3002" };
|
|
}
|
|
const type = typeof info.type === "string" ? info.type.toLowerCase() : "";
|
|
const importer = typeof info.importer === "string" ? info.importer.toLowerCase() : "";
|
|
if (type.includes("spriteframe") || importer.includes("sprite-frame") || importer.includes("spriteframe")) {
|
|
return { ok: true, uuid };
|
|
}
|
|
const subUuid = extractSpriteFrameUuidFromSubAssets(info.subAssets);
|
|
if (subUuid) {
|
|
return { ok: true, uuid: subUuid };
|
|
}
|
|
const rawMeta = await Editor.Message.request("asset-db", "query-asset-meta", uuid);
|
|
const meta = parseAssetMeta(rawMeta);
|
|
const metaSubUuid = extractSpriteFrameUuidFromSubAssets(meta?.subMetas);
|
|
if (metaSubUuid) {
|
|
return { ok: true, uuid: metaSubUuid };
|
|
}
|
|
return {
|
|
ok: false,
|
|
message: "\u4F20\u5165\u7684\u8D44\u6E90\u4E0D\u662F SpriteFrame\uFF0C\u4E14\u65E0\u6CD5\u4ECE subAssets \u81EA\u52A8\u89E3\u6790 SpriteFrame \u5B50\u8D44\u6E90\u3002\u8BF7\u4F20\u5165 SpriteFrame UUID\u3002"
|
|
};
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { ok: false, message: `asset-db query failed: ${message}` };
|
|
}
|
|
}
|
|
async function resolveFontUuid(uuid) {
|
|
try {
|
|
const info = await Editor.Message.request("asset-db", "query-asset-info", uuid);
|
|
if (!info || typeof info !== "object") {
|
|
return { ok: false, message: "\u5B57\u4F53\u8D44\u6E90 UUID \u65E0\u6548\u6216\u672A\u5BFC\u5165\u5B8C\u6210\u3002" };
|
|
}
|
|
const type = typeof info.type === "string" ? info.type.toLowerCase() : "";
|
|
const importer = typeof info.importer === "string" ? info.importer.toLowerCase() : "";
|
|
if (type.includes("font") || importer.includes("font") || importer.includes("ttf") || importer.includes("bitmap-font") || importer.includes("bmfont")) {
|
|
return { ok: true, uuid };
|
|
}
|
|
return { ok: false, message: "\u4F20\u5165\u7684\u8D44\u6E90\u4E0D\u662F\u5B57\u4F53\uFF08Font\uFF09\u8D44\u6E90\u3002\u8BF7\u4F20\u5165 Font UUID\u3002" };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { ok: false, message: `asset-db query failed: ${message}` };
|
|
}
|
|
}
|
|
function readProjectLogTail(relPath, maxLines) {
|
|
const full = path3.join(Editor.Project.path, relPath);
|
|
if (!fs3.existsSync(full)) {
|
|
return { ok: false, message: `Log not found: ${relPath}` };
|
|
}
|
|
try {
|
|
const text = fs3.readFileSync(full, "utf-8");
|
|
const lines = text.split(/\r?\n/);
|
|
const tail = lines.slice(-maxLines).join("\n");
|
|
return { ok: true, text: tail };
|
|
} catch (e) {
|
|
const m = e instanceof Error ? e.message : String(e);
|
|
return { ok: false, message: m };
|
|
}
|
|
}
|
|
function handleProjectGetInfo(id) {
|
|
try {
|
|
const result = {
|
|
ok: true,
|
|
projectName: Editor.Project.name,
|
|
projectPath: Editor.Project.path,
|
|
creatorVersion: Editor.App.version
|
|
};
|
|
return { op: "project_get_info_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "project_get_info_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
function normalizeExtensionEntries(raw) {
|
|
if (raw === void 0 || raw === null) {
|
|
return { ok: true, set: /* @__PURE__ */ new Set() };
|
|
}
|
|
if (!Array.isArray(raw)) {
|
|
return { ok: false, message: "extensions must be an array of strings" };
|
|
}
|
|
if (raw.length > 64) {
|
|
return { ok: false, message: "extensions array too long (max 64)" };
|
|
}
|
|
const set = /* @__PURE__ */ new Set();
|
|
for (const x of raw) {
|
|
if (typeof x !== "string" || !x.trim()) {
|
|
return { ok: false, message: "each extension must be a non-empty string" };
|
|
}
|
|
let s = x.trim().toLowerCase();
|
|
if (!s.startsWith(".")) {
|
|
s = `.${s}`;
|
|
}
|
|
if (!/^\.[a-z0-9][a-z0-9._-]*$/i.test(s) || s.length > 32) {
|
|
return { ok: false, message: `invalid extension token: ${x}` };
|
|
}
|
|
set.add(s);
|
|
}
|
|
return { ok: true, set };
|
|
}
|
|
function resolveAssetsScanRoot(relRaw) {
|
|
const assetsRoot = path3.join(Editor.Project.path, "assets");
|
|
if (!fs3.existsSync(assetsRoot)) {
|
|
return { ok: false, message: "assets/ directory not found" };
|
|
}
|
|
const rel = typeof relRaw === "string" && relRaw.trim() ? relRaw.trim().replace(/\\/g, "/") : "";
|
|
if (rel.includes("..")) {
|
|
return { ok: false, message: "relativeDir must not contain .." };
|
|
}
|
|
if (path3.isAbsolute(rel)) {
|
|
return { ok: false, message: "relativeDir must be relative" };
|
|
}
|
|
const full = path3.resolve(assetsRoot, rel);
|
|
const normRoot = path3.resolve(assetsRoot);
|
|
const relFromRoot = path3.relative(normRoot, full);
|
|
if (relFromRoot.startsWith("..") || path3.isAbsolute(relFromRoot)) {
|
|
return { ok: false, message: "relativeDir must stay under assets/" };
|
|
}
|
|
if (!fs3.existsSync(full)) {
|
|
return { ok: false, message: "relativeDir does not exist under assets/" };
|
|
}
|
|
const st = fs3.statSync(full);
|
|
if (!st.isDirectory()) {
|
|
return { ok: false, message: "relativeDir must be a directory under assets/" };
|
|
}
|
|
return { ok: true, abs: full };
|
|
}
|
|
function collectListAssetFiles(params) {
|
|
const { scanRootAbs, projectRoot, maxResults, extFilter } = params;
|
|
const paths = [];
|
|
let truncated = false;
|
|
const projectRootNorm = path3.resolve(projectRoot);
|
|
const walk = (dir) => {
|
|
if (paths.length >= maxResults) {
|
|
truncated = true;
|
|
return;
|
|
}
|
|
let entries;
|
|
try {
|
|
entries = fs3.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
for (const ent of entries) {
|
|
if (paths.length >= maxResults) {
|
|
truncated = true;
|
|
return;
|
|
}
|
|
const full = path3.join(dir, ent.name);
|
|
if (ent.isDirectory()) {
|
|
walk(full);
|
|
} else if (ent.isFile()) {
|
|
const ext = path3.extname(ent.name).toLowerCase();
|
|
if (extFilter && extFilter.size > 0 && !extFilter.has(ext)) {
|
|
continue;
|
|
}
|
|
const rel = path3.relative(projectRootNorm, path3.resolve(full)).replace(/\\/g, "/");
|
|
if (!rel.startsWith("assets/")) {
|
|
continue;
|
|
}
|
|
paths.push(rel);
|
|
if (paths.length >= maxResults) {
|
|
truncated = true;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
walk(scanRootAbs);
|
|
paths.sort((a, b) => a.localeCompare(b));
|
|
return { ok: true, paths, truncated };
|
|
}
|
|
function handleProjectListAssets(id, payload) {
|
|
try {
|
|
const pl = payload !== void 0 && payload !== null && isRecord(payload) ? payload : {};
|
|
const maxResults = Math.min(2e3, Math.max(1, Number(pl.maxResults) || 200));
|
|
const rootRes = resolveAssetsScanRoot(pl.relativeDir);
|
|
if (!rootRes.ok) {
|
|
return { op: "project_list_assets_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: rootRes.message };
|
|
}
|
|
const extRes = normalizeExtensionEntries(pl.extensions);
|
|
if (!extRes.ok) {
|
|
return { op: "project_list_assets_err", id, v: SUPPORTED_PROTOCOL_VERSION, message: extRes.message };
|
|
}
|
|
const extFilter = extRes.set.size > 0 ? extRes.set : null;
|
|
const result = collectListAssetFiles({
|
|
scanRootAbs: rootRes.abs,
|
|
projectRoot: Editor.Project.path,
|
|
maxResults,
|
|
extFilter
|
|
});
|
|
return { op: "project_list_assets_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "project_list_assets_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
async function handleProjectGetPrefabInfo(id, payload) {
|
|
try {
|
|
const pl = payload !== void 0 && payload !== null && isRecord(payload) ? payload : {};
|
|
const prefabPathRaw = pl.prefabPath;
|
|
if (typeof prefabPathRaw !== "string") {
|
|
return {
|
|
op: "project_get_prefab_info_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires string prefabPath"
|
|
};
|
|
}
|
|
const pathNorm = normalizePrefabAssetRelativePath(prefabPathRaw);
|
|
if (!pathNorm.ok) {
|
|
return {
|
|
op: "project_get_prefab_info_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: pathNorm.message
|
|
};
|
|
}
|
|
const dbUrl = `db://${pathNorm.rel}`;
|
|
const info = await Editor.Message.request("asset-db", "query-asset-info", dbUrl);
|
|
if (!info || typeof info !== "object") {
|
|
return {
|
|
op: "project_get_prefab_info_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "Prefab asset not found in asset-db (check path and import)."
|
|
};
|
|
}
|
|
if (info.isDirectory === true) {
|
|
return {
|
|
op: "project_get_prefab_info_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "prefabPath must be a file, not a directory."
|
|
};
|
|
}
|
|
const type = typeof info.type === "string" ? info.type : "";
|
|
const importer = typeof info.importer === "string" ? info.importer : "";
|
|
const ty = type.toLowerCase();
|
|
const im = importer.toLowerCase();
|
|
if (!ty.includes("prefab") && !im.includes("prefab")) {
|
|
return {
|
|
op: "project_get_prefab_info_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "Asset is not a prefab (unexpected type/importer)."
|
|
};
|
|
}
|
|
const name = typeof info.name === "string" ? info.name : "";
|
|
const displayName = typeof info.displayName === "string" ? info.displayName : name;
|
|
const imported = info.imported === true;
|
|
const invalid = info.invalid === true;
|
|
let subAssetCount;
|
|
const sub = info.subAssets;
|
|
if (sub && typeof sub === "object" && !Array.isArray(sub)) {
|
|
subAssetCount = Object.keys(sub).length;
|
|
}
|
|
let dependencyCount;
|
|
try {
|
|
const deps = await Editor.Message.request("asset-db", "query-asset-dependencies", dbUrl);
|
|
if (Array.isArray(deps)) {
|
|
dependencyCount = deps.length;
|
|
}
|
|
} catch {
|
|
}
|
|
let extendsTypes;
|
|
const ext = info.extends;
|
|
if (Array.isArray(ext)) {
|
|
const arr = ext.filter((x) => typeof x === "string");
|
|
if (arr.length > 0) {
|
|
extendsTypes = arr;
|
|
}
|
|
}
|
|
const instantiation = typeof info.instantiation === "string" ? info.instantiation : void 0;
|
|
const result = {
|
|
ok: true,
|
|
prefabPath: pathNorm.rel,
|
|
name,
|
|
displayName,
|
|
type,
|
|
importer,
|
|
imported,
|
|
invalid
|
|
};
|
|
if (subAssetCount !== void 0) {
|
|
result.subAssetCount = subAssetCount;
|
|
}
|
|
if (dependencyCount !== void 0) {
|
|
result.dependencyCount = dependencyCount;
|
|
}
|
|
if (extendsTypes) {
|
|
result.extendsTypes = extendsTypes;
|
|
}
|
|
if (instantiation) {
|
|
result.instantiation = instantiation;
|
|
}
|
|
return { op: "project_get_prefab_info_ok", id, v: SUPPORTED_PROTOCOL_VERSION, result };
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { op: "project_get_prefab_info_err", id, v: SUPPORTED_PROTOCOL_VERSION, message };
|
|
}
|
|
}
|
|
function isRecord(v) {
|
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
}
|
|
function safeSend(ws, obj) {
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify(obj));
|
|
}
|
|
}
|
|
function terminateServerClients(server) {
|
|
for (const client of server.clients) {
|
|
try {
|
|
client.close(1001, "server stopping");
|
|
client.terminate();
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
function startWebSocketServer() {
|
|
if (wss) {
|
|
return Promise.resolve();
|
|
}
|
|
if (startServerPromise) {
|
|
return startServerPromise;
|
|
}
|
|
if (serverStopping || stopServerPromise) {
|
|
return Promise.reject(new Error("WebSocket server is still stopping."));
|
|
}
|
|
const starting = new Promise((resolve2, reject) => {
|
|
removeDiscovery();
|
|
serverToken = randomToken();
|
|
let settled = false;
|
|
const failStart = (error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
if (pendingWss === server) {
|
|
pendingWss = null;
|
|
}
|
|
if (wss === server) {
|
|
wss = null;
|
|
}
|
|
if (cancelPendingStart === failStart) {
|
|
cancelPendingStart = null;
|
|
}
|
|
terminateServerClients(server);
|
|
removeDiscovery();
|
|
serverToken = "";
|
|
const finish = () => reject(error);
|
|
try {
|
|
server.close(finish);
|
|
} catch {
|
|
finish();
|
|
}
|
|
};
|
|
const server = new import_ws.WebSocketServer({ host: "127.0.0.1", port: 0 }, () => {
|
|
if (serverStopping) {
|
|
failStart(new Error("WebSocket server start was canceled by stop."));
|
|
return;
|
|
}
|
|
try {
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === "string") {
|
|
throw new Error("WebSocketServer: invalid address");
|
|
}
|
|
writeDiscovery(addr.port, serverToken);
|
|
wss = server;
|
|
pendingWss = null;
|
|
console.info(`[cocos-mcp] WebSocket listening 127.0.0.1:${addr.port}, discovery \u2192 ${getDiscoveryPath()}`);
|
|
cancelPendingStart = null;
|
|
settled = true;
|
|
resolve2();
|
|
} catch (error) {
|
|
failStart(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
});
|
|
pendingWss = server;
|
|
cancelPendingStart = failStart;
|
|
server.on("error", (err) => {
|
|
failStart(err);
|
|
});
|
|
server.on("connection", (ws, req) => {
|
|
const parsed = url.parse(req.url || "", true);
|
|
const token = parsed.query.token;
|
|
const tokenStr = Array.isArray(token) ? token[0] : token;
|
|
if (tokenStr !== serverToken) {
|
|
ws.close(4001, "unauthorized");
|
|
return;
|
|
}
|
|
activeWsClients.add(ws);
|
|
panelLog("info", `client connected (${activeWsClients.size} online)`);
|
|
ws.on("close", () => {
|
|
activeWsClients.delete(ws);
|
|
panelLog("info", `client disconnected (${activeWsClients.size} online)`);
|
|
});
|
|
ws.on("message", (raw) => {
|
|
if (serverStopping) {
|
|
ws.close(1012, "server stopping");
|
|
return;
|
|
}
|
|
wsRequestQueue = wsRequestQueue.then(async () => {
|
|
let parsedMsg;
|
|
try {
|
|
parsedMsg = JSON.parse(raw.toString());
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!isRecord(parsedMsg)) {
|
|
return;
|
|
}
|
|
const op = parsedMsg.op;
|
|
const id = parsedMsg.id;
|
|
if (typeof id !== "string" || typeof op !== "string") {
|
|
return;
|
|
}
|
|
const verRaw = parsedMsg.v;
|
|
if (verRaw !== void 0 && verRaw !== null && Number(verRaw) !== SUPPORTED_PROTOCOL_VERSION) {
|
|
safeSend(ws, {
|
|
op: "scene_protocol_err",
|
|
id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: `Unsupported protocol version: ${String(verRaw)}; expected ${SUPPORTED_PROTOCOL_VERSION}`
|
|
});
|
|
return;
|
|
}
|
|
const env = parsedMsg;
|
|
if (env.op === "scene_ping") {
|
|
safeSend(ws, await handleScenePing(env.id));
|
|
return;
|
|
}
|
|
if (env.op === "project_log_tail") {
|
|
const pl = isRecord(parsedMsg.payload) ? parsedMsg.payload : {};
|
|
const maxLines = Math.min(2e3, Math.max(1, Number(pl.maxLines) || 200));
|
|
const rel = safeRelativeProjectPath(pl.relativePath, DEFAULT_LOG_RELATIVE);
|
|
const out = readProjectLogTail(rel, maxLines);
|
|
if (!out.ok) {
|
|
safeSend(ws, {
|
|
op: "project_log_tail_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: out.message
|
|
});
|
|
} else {
|
|
safeSend(ws, {
|
|
op: "project_log_tail_ok",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
result: out.text
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (env.op === "project_get_info") {
|
|
safeSend(ws, handleProjectGetInfo(env.id));
|
|
return;
|
|
}
|
|
if (env.op === "project_list_assets") {
|
|
safeSend(ws, handleProjectListAssets(env.id, parsedMsg.payload));
|
|
return;
|
|
}
|
|
if (env.op === "project_get_prefab_info") {
|
|
safeSend(ws, await handleProjectGetPrefabInfo(env.id, parsedMsg.payload));
|
|
return;
|
|
}
|
|
if (env.op === "scene_get_hierarchy") {
|
|
const pl = env.payload;
|
|
const hierarchyArg = pl !== void 0 && pl !== null && isRecord(pl) ? pl : {};
|
|
let nextSceneUuid = "";
|
|
try {
|
|
const meta = await executeSceneMethod("getCurrentSceneMeta", []);
|
|
nextSceneUuid = inodeRecord(meta) && meta.ok === true && meta.hasScene === true && typeof meta.sceneAssetUuid === "string" ? meta.sceneAssetUuid : "";
|
|
} catch {
|
|
}
|
|
if (mcpUndoSceneUuid && nextSceneUuid && mcpUndoSceneUuid !== nextSceneUuid) {
|
|
await clearMcpUndoStack();
|
|
}
|
|
if (nextSceneUuid) {
|
|
mcpUndoSceneUuid = nextSceneUuid;
|
|
}
|
|
safeSend(ws, await forwardToScene(env.id, "getHierarchy", [hierarchyArg]));
|
|
return;
|
|
}
|
|
if (env.op === "scene_get_current_meta") {
|
|
safeSend(ws, await handleGetCurrentSceneMeta(env.id));
|
|
return;
|
|
}
|
|
if (env.op === "scene_search_nodes") {
|
|
const pl = env.payload;
|
|
const searchArg = pl !== void 0 && pl !== null && isRecord(pl) ? pl : {};
|
|
safeSend(ws, await forwardToScene(env.id, "searchNodes", [searchArg]));
|
|
return;
|
|
}
|
|
if (env.op === "scene_inspect_node") {
|
|
const pl = env.payload;
|
|
const inspectArg = pl !== void 0 && pl !== null && isRecord(pl) ? pl : {};
|
|
safeSend(ws, await forwardToScene(env.id, "inspectNode", [inspectArg]));
|
|
return;
|
|
}
|
|
if (env.op === "scene_set_node_active") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_set_node_active_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with integer nid and boolean active"
|
|
});
|
|
return;
|
|
}
|
|
const nid = pl.nid;
|
|
const active = pl.active;
|
|
if (typeof nid !== "number" || !Number.isInteger(nid) || typeof active !== "boolean") {
|
|
safeSend(ws, {
|
|
op: "scene_set_node_active_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer nid and boolean active"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(ws, await handleSetNodeActive(env.id, { nid, active }));
|
|
return;
|
|
}
|
|
if (env.op === "scene_set_node_transform") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_set_node_transform_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with nid and at least one of position, rotationEuler, scale"
|
|
});
|
|
return;
|
|
}
|
|
const nid = pl.nid;
|
|
if (typeof nid !== "number" || !Number.isInteger(nid)) {
|
|
safeSend(ws, {
|
|
op: "scene_set_node_transform_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer nid"
|
|
});
|
|
return;
|
|
}
|
|
const position = parseFiniteVec3(pl.position);
|
|
const rotationEuler = parseFiniteVec3(pl.rotationEuler);
|
|
const scale = parseFiniteVec3(pl.scale);
|
|
if (!position && !rotationEuler && !scale) {
|
|
safeSend(ws, {
|
|
op: "scene_set_node_transform_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "provide at least one of position, rotationEuler, scale as { x, y, z } finite numbers"
|
|
});
|
|
return;
|
|
}
|
|
const payload = { nid };
|
|
if (position) {
|
|
payload.position = position;
|
|
}
|
|
if (rotationEuler) {
|
|
payload.rotationEuler = rotationEuler;
|
|
}
|
|
if (scale) {
|
|
payload.scale = scale;
|
|
}
|
|
safeSend(ws, await handleSetNodeTransform(env.id, payload));
|
|
return;
|
|
}
|
|
if (env.op === "scene_set_node_name") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_set_node_name_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with integer nid and string name"
|
|
});
|
|
return;
|
|
}
|
|
const nid = pl.nid;
|
|
const name = pl.name;
|
|
if (typeof nid !== "number" || !Number.isInteger(nid) || typeof name !== "string") {
|
|
safeSend(ws, {
|
|
op: "scene_set_node_name_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer nid and string name"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(ws, await handleSetNodeName(env.id, { nid, name }));
|
|
return;
|
|
}
|
|
if (env.op === "scene_create_node") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_create_node_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with integer parentNid and string name"
|
|
});
|
|
return;
|
|
}
|
|
const parentNid = pl.parentNid;
|
|
const cname = pl.name;
|
|
if (typeof parentNid !== "number" || !Number.isInteger(parentNid) || typeof cname !== "string") {
|
|
safeSend(ws, {
|
|
op: "scene_create_node_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer parentNid and string name"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(ws, await handleCreateNode(env.id, { parentNid, name: cname }));
|
|
return;
|
|
}
|
|
if (env.op === "scene_delete_node") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_delete_node_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with integer nid and confirm: true"
|
|
});
|
|
return;
|
|
}
|
|
const nid = pl.nid;
|
|
const confirm = pl.confirm;
|
|
const cascade = pl.cascade === true;
|
|
if (typeof nid !== "number" || !Number.isInteger(nid) || confirm !== true) {
|
|
safeSend(ws, {
|
|
op: "scene_delete_node_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer nid and confirm: true (literal)"
|
|
});
|
|
return;
|
|
}
|
|
const delPayload = { nid, confirm: true };
|
|
if (cascade) {
|
|
delPayload.cascade = true;
|
|
}
|
|
safeSend(ws, await handleDeleteNode(env.id, delPayload));
|
|
return;
|
|
}
|
|
if (env.op === "scene_reparent_node") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_reparent_node_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with integer childNid and parentNid"
|
|
});
|
|
return;
|
|
}
|
|
const childNid = pl.childNid;
|
|
const parentNid = pl.parentNid;
|
|
if (typeof childNid !== "number" || !Number.isInteger(childNid)) {
|
|
safeSend(ws, {
|
|
op: "scene_reparent_node_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer childNid"
|
|
});
|
|
return;
|
|
}
|
|
if (typeof parentNid !== "number" || !Number.isInteger(parentNid)) {
|
|
safeSend(ws, {
|
|
op: "scene_reparent_node_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer parentNid"
|
|
});
|
|
return;
|
|
}
|
|
const rp = { childNid, parentNid };
|
|
if (pl.keepWorldTransform === false) {
|
|
rp.keepWorldTransform = false;
|
|
}
|
|
safeSend(ws, await handleReparentNode(env.id, rp));
|
|
return;
|
|
}
|
|
if (env.op === "scene_set_node_sibling_index") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_set_node_sibling_index_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with integer nid and non-negative integer index"
|
|
});
|
|
return;
|
|
}
|
|
const nid = pl.nid;
|
|
const index = pl.index;
|
|
if (typeof nid !== "number" || !Number.isInteger(nid) || typeof index !== "number" || !Number.isInteger(index) || index < 0) {
|
|
safeSend(ws, {
|
|
op: "scene_set_node_sibling_index_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer nid and non-negative integer index"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(ws, await handleSetNodeSiblingIndex(env.id, { nid, index }));
|
|
return;
|
|
}
|
|
if (env.op === "scene_instantiate_prefab") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_instantiate_prefab_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with integer parentNid and string prefabPath"
|
|
});
|
|
return;
|
|
}
|
|
const parentNid = pl.parentNid;
|
|
const prefabPath = pl.prefabPath;
|
|
if (typeof parentNid !== "number" || !Number.isInteger(parentNid)) {
|
|
safeSend(ws, {
|
|
op: "scene_instantiate_prefab_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer parentNid"
|
|
});
|
|
return;
|
|
}
|
|
if (typeof prefabPath !== "string") {
|
|
safeSend(ws, {
|
|
op: "scene_instantiate_prefab_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires string prefabPath"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(ws, await handleInstantiatePrefab(env.id, { parentNid, prefabPath }));
|
|
return;
|
|
}
|
|
if (env.op === "scene_add_component") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_add_component_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with integer nid and string componentType"
|
|
});
|
|
return;
|
|
}
|
|
const nid = pl.nid;
|
|
const componentType = pl.componentType;
|
|
if (typeof nid !== "number" || !Number.isInteger(nid) || typeof componentType !== "string") {
|
|
safeSend(ws, {
|
|
op: "scene_add_component_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer nid and string componentType"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(ws, await handleAddComponent(env.id, { nid, componentType }));
|
|
return;
|
|
}
|
|
if (env.op === "scene_remove_component") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_remove_component_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with integer nid and componentIndex (and optional expectedComponentType)"
|
|
});
|
|
return;
|
|
}
|
|
const nid = pl.nid;
|
|
const componentIndex = pl.componentIndex;
|
|
const expectedComponentType = typeof pl.expectedComponentType === "string" ? pl.expectedComponentType : void 0;
|
|
if (typeof nid !== "number" || !Number.isInteger(nid) || typeof componentIndex !== "number" || !Number.isInteger(componentIndex) || componentIndex < 0) {
|
|
safeSend(ws, {
|
|
op: "scene_remove_component_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer nid and non-negative integer componentIndex"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(ws, await handleRemoveComponent(env.id, { nid, componentIndex, expectedComponentType }));
|
|
return;
|
|
}
|
|
if (env.op === "scene_duplicate_node") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl) || typeof pl.nid !== "number" || !Number.isInteger(pl.nid)) {
|
|
safeSend(ws, {
|
|
op: "scene_duplicate_node_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer nid"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(ws, await handleDuplicateNode(env.id, { nid: pl.nid }));
|
|
return;
|
|
}
|
|
if (env.op === "scene_set_component_property") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_set_component_property_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload must be an object with nid, componentIndex, property, value"
|
|
});
|
|
return;
|
|
}
|
|
const nid = pl.nid;
|
|
const componentIndex = pl.componentIndex;
|
|
const property = pl.property;
|
|
if (typeof nid !== "number" || !Number.isInteger(nid)) {
|
|
safeSend(ws, {
|
|
op: "scene_set_component_property_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer nid"
|
|
});
|
|
return;
|
|
}
|
|
if (typeof componentIndex !== "number" || !Number.isInteger(componentIndex) || componentIndex < 0) {
|
|
safeSend(ws, {
|
|
op: "scene_set_component_property_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires non-negative integer componentIndex"
|
|
});
|
|
return;
|
|
}
|
|
if (typeof property !== "string" || !property.trim()) {
|
|
safeSend(ws, {
|
|
op: "scene_set_component_property_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires non-empty string property"
|
|
});
|
|
return;
|
|
}
|
|
if (!("value" in pl)) {
|
|
safeSend(ws, {
|
|
op: "scene_set_component_property_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires value"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(
|
|
ws,
|
|
await handleSetComponentProperty(env.id, {
|
|
nid,
|
|
componentIndex,
|
|
property,
|
|
value: pl.value
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
if (env.op === "scene_apply_ui_spec") {
|
|
const pl = parsedMsg.payload;
|
|
if (!isRecord(pl) || typeof pl.parentNid !== "number" || !Number.isInteger(pl.parentNid) || !Array.isArray(pl.nodes)) {
|
|
safeSend(ws, {
|
|
op: "scene_apply_ui_spec_err",
|
|
id: env.id,
|
|
v: SUPPORTED_PROTOCOL_VERSION,
|
|
message: "payload requires integer parentNid and a nodes array"
|
|
});
|
|
return;
|
|
}
|
|
safeSend(ws, await handleApplyUiSpec(env.id, pl));
|
|
return;
|
|
}
|
|
if (env.op === "editor_undo") {
|
|
safeSend(ws, await handleEditorUndo(env.id));
|
|
return;
|
|
}
|
|
if (env.op === "scene_save") {
|
|
safeSend(ws, await handleSaveScene(env.id));
|
|
return;
|
|
}
|
|
if (env.op === "mcp_undo_last") {
|
|
safeSend(ws, await handleMcpUndoLast(env.id));
|
|
return;
|
|
}
|
|
}).catch((error) => {
|
|
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
console.error("[cocos-mcp] queued request failed", message);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
startServerPromise = starting;
|
|
const clearStarting = () => {
|
|
if (startServerPromise === starting) {
|
|
startServerPromise = null;
|
|
cancelPendingStart = null;
|
|
}
|
|
if (pendingWss && !wss) {
|
|
pendingWss = null;
|
|
}
|
|
};
|
|
starting.then(clearStarting, clearStarting);
|
|
return starting;
|
|
}
|
|
function stopWebSocketServer() {
|
|
if (stopServerPromise) {
|
|
return stopServerPromise;
|
|
}
|
|
stopServerPromise = (async () => {
|
|
serverStopping = true;
|
|
const pendingStart = startServerPromise;
|
|
if (pendingStart) {
|
|
cancelPendingStart?.(new Error("WebSocket server start was canceled by stop."));
|
|
await pendingStart.catch(() => {
|
|
});
|
|
}
|
|
const server = wss;
|
|
wss = null;
|
|
if (server) {
|
|
terminateServerClients(server);
|
|
}
|
|
const serverClosed = server ? new Promise((resolve2) => {
|
|
server.close(() => resolve2());
|
|
}) : Promise.resolve();
|
|
await wsRequestQueue;
|
|
await serverClosed;
|
|
await clearMcpUndoStack();
|
|
activeWsClients.clear();
|
|
removeDiscovery();
|
|
serverToken = "";
|
|
wsRequestQueue = Promise.resolve();
|
|
})().finally(() => {
|
|
serverStopping = false;
|
|
stopServerPromise = null;
|
|
});
|
|
return stopServerPromise;
|
|
}
|
|
async function load() {
|
|
try {
|
|
await startWebSocketServer();
|
|
} catch (err) {
|
|
console.error("[cocos-mcp] failed to start WebSocket server", err);
|
|
throw err;
|
|
}
|
|
}
|
|
async function unload() {
|
|
try {
|
|
await stopWebSocketServer();
|
|
} catch (err) {
|
|
console.error("[cocos-mcp] failed to stop WebSocket server", err);
|
|
throw err;
|
|
}
|
|
}
|
|
function getRuntimeStatus() {
|
|
if (!wss) {
|
|
return { running: false, port: null, url: null, connections: 0 };
|
|
}
|
|
const addr = wss.address();
|
|
const port = addr && typeof addr !== "string" ? addr.port : null;
|
|
return {
|
|
running: true,
|
|
port,
|
|
url: port ? `http://127.0.0.1:${port}/mcp` : null,
|
|
connections: activeWsClients.size
|
|
};
|
|
}
|
|
function clientConfigured(spec, serverName, bridge) {
|
|
if (spec.mode === "json") {
|
|
return hasServerInJson(spec.configPath, serverName, bridge);
|
|
}
|
|
return hasServerInCodexToml(spec.configPath, serverName, bridge);
|
|
}
|
|
function addServerToClient(spec, serverName, bridge) {
|
|
if (spec.mode === "json") {
|
|
return writeJsonConfig(spec.configPath, serverName, bridge);
|
|
}
|
|
return writeCodexToml(spec.configPath, serverName, bridge);
|
|
}
|
|
var latestSelfTest = null;
|
|
async function runMcpSelfTest(serverName = "cocos-creator") {
|
|
const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
const checks = [];
|
|
const bridgeCfg = getBridgeLaunchConfig();
|
|
if (!bridgeCfg.ok) {
|
|
checks.push({ id: "node", ok: false, message: bridgeCfg.message });
|
|
checks.push({ id: "bridge", ok: false, message: bridgeCfg.message });
|
|
} else {
|
|
const bridge = bridgeCfg.value;
|
|
const nodeProbe = (0, import_child_process.spawnSync)(bridge.nodeBin, ["--version"], {
|
|
encoding: "utf-8",
|
|
timeout: 5e3,
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
checks.push({
|
|
id: "node",
|
|
ok: !nodeProbe.error && nodeProbe.status === 0,
|
|
message: nodeProbe.error ? nodeProbe.error.message : `${bridge.nodeBin} ${(nodeProbe.stdout ?? "").trim() || "(version unavailable)"}`
|
|
});
|
|
const bridgeProbe = (0, import_child_process.spawnSync)(
|
|
bridge.nodeBin,
|
|
[bridge.bridgeCliPath, "--project", bridge.projectRoot, "--self-test"],
|
|
{
|
|
encoding: "utf-8",
|
|
timeout: 8e3,
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
}
|
|
);
|
|
const bridgeOutput = `${bridgeProbe.stdout ?? ""}`.trim();
|
|
checks.push({
|
|
id: "bridge",
|
|
ok: !bridgeProbe.error && bridgeProbe.status === 0,
|
|
message: bridgeProbe.error ? bridgeProbe.error.message : bridgeOutput || `${bridgeProbe.stderr ?? ""}`.trim() || "bridge self-test returned no output"
|
|
});
|
|
}
|
|
const server = getRuntimeStatus();
|
|
checks.push({
|
|
id: "websocket",
|
|
ok: server.running && server.port !== null,
|
|
message: server.running ? `listening on 127.0.0.1:${server.port} (${server.connections} client connection(s))` : "MCP WebSocket server is stopped"
|
|
});
|
|
try {
|
|
const ping = await executeSceneMethod("ping", []);
|
|
checks.push({
|
|
id: "scene",
|
|
ok: ping?.status === "ok" && ping?.layer === "scene",
|
|
message: ping?.status === "ok" ? "Creator Scene process responded" : "unexpected Scene ping response"
|
|
});
|
|
} catch (error) {
|
|
checks.push({
|
|
id: "scene",
|
|
ok: false,
|
|
message: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
const configuredCount = bridgeCfg.ok ? getClientSpecs().filter((spec) => clientConfigured(spec, serverName, bridgeCfg.value)).length : 0;
|
|
checks.push({
|
|
id: "clients",
|
|
ok: configuredCount > 0,
|
|
message: configuredCount > 0 ? `${configuredCount} MCP client configuration(s) verified` : "No verified MCP client configuration; run Auto Config"
|
|
});
|
|
latestSelfTest = { ok: checks.every((check) => check.ok), checkedAt, checks };
|
|
for (const check of checks) {
|
|
panelLog(check.ok ? "info" : "warn", `[self-test:${check.id}] ${check.message}`);
|
|
}
|
|
return latestSelfTest;
|
|
}
|
|
var methods = {
|
|
openPanel() {
|
|
Editor.Panel.open("cocos-mcp");
|
|
},
|
|
openUserGuide() {
|
|
const guidePath = path3.resolve(__dirname, "..", "QUICK_START.md");
|
|
const out = fs3.existsSync(guidePath) ? openFileInSystem(guidePath) : { ok: false, message: `Quick Start not found: ${guidePath}` };
|
|
panelLog(out.ok ? "info" : "warn", `[quick-start] ${out.message}`);
|
|
return out;
|
|
},
|
|
async startMcpServer() {
|
|
if (wss) {
|
|
return;
|
|
}
|
|
await startWebSocketServer();
|
|
panelLog("info", "server started from panel");
|
|
},
|
|
async stopMcpServer() {
|
|
await stopWebSocketServer();
|
|
panelLog("info", "server stopped from panel");
|
|
},
|
|
getMcpServerStatus() {
|
|
return getRuntimeStatus();
|
|
},
|
|
async runMcpSelfTest(serverName = "cocos-creator") {
|
|
return runMcpSelfTest(serverName);
|
|
},
|
|
getMcpPanelState(serverName = "cocos-creator") {
|
|
const server = getRuntimeStatus();
|
|
const bridgeCfg = getBridgeLaunchConfig();
|
|
const bridge = bridgeCfg.ok ? bridgeCfg.value : null;
|
|
const clients = getClientSpecs().map((spec) => ({
|
|
clientType: spec.clientType,
|
|
name: spec.name,
|
|
configPath: spec.configPath,
|
|
configured: bridge ? clientConfigured(spec, serverName, bridge) : false,
|
|
exists: fs3.existsSync(spec.configPath),
|
|
mode: spec.mode
|
|
}));
|
|
const launch = bridge ? bridge.args.length > 0 ? `${bridge.command} ${bridge.args.map((a) => `"${a}"`).join(" ")}` : `"${bridge.command}"` : 'node "<extension>/dist/bridge-cli.js" --project "<Cocos project>"';
|
|
const cliCommands = {
|
|
claude: `claude mcp add --scope user ${serverName} -- ${launch}`,
|
|
gemini: `gemini mcp add --scope user ${serverName} -- ${launch}`
|
|
};
|
|
if (!bridgeCfg.ok) {
|
|
panelLog("warn", bridgeCfg.message);
|
|
}
|
|
return {
|
|
server,
|
|
serverName,
|
|
clients,
|
|
cliCommands,
|
|
selfTest: latestSelfTest,
|
|
logs: [...panelLogs]
|
|
};
|
|
},
|
|
addMcpToClient(clientType, serverName = "cocos-creator") {
|
|
const spec = getClientSpecs().find((c) => c.clientType === clientType);
|
|
if (!spec) {
|
|
return { ok: false, message: `unknown clientType: ${clientType}` };
|
|
}
|
|
const bridgeCfg = getBridgeLaunchConfig();
|
|
if (!bridgeCfg.ok) {
|
|
panelLog("error", `[auto-config:${spec.clientType}] ${bridgeCfg.message}`);
|
|
return { ok: false, message: bridgeCfg.message };
|
|
}
|
|
const out = addServerToClient(spec, serverName, bridgeCfg.value);
|
|
panelLog(out.ok ? "info" : "error", `[auto-config:${spec.clientType}] ${out.message}`);
|
|
return out;
|
|
},
|
|
addMcpToAllClients(serverName = "cocos-creator") {
|
|
const specs = getClientSpecs();
|
|
const out = {};
|
|
let allOk = true;
|
|
const bridgeCfg = getBridgeLaunchConfig();
|
|
if (!bridgeCfg.ok) {
|
|
panelLog("error", `[auto-config:all] ${bridgeCfg.message}`);
|
|
for (const spec of specs) {
|
|
out[spec.clientType] = bridgeCfg.message;
|
|
}
|
|
return { ok: false, results: out };
|
|
}
|
|
for (const spec of specs) {
|
|
const r = addServerToClient(spec, serverName, bridgeCfg.value);
|
|
out[spec.clientType] = r.message;
|
|
if (!r.ok) {
|
|
allOk = false;
|
|
}
|
|
panelLog(r.ok ? "info" : "error", `[auto-config:${spec.clientType}] ${r.message}`);
|
|
}
|
|
return { ok: allOk, results: out };
|
|
},
|
|
openClientConfig(clientType) {
|
|
const spec = getClientSpecs().find((c) => c.clientType === clientType);
|
|
if (!spec) {
|
|
return { ok: false, message: `unknown clientType: ${clientType}` };
|
|
}
|
|
ensureParentDir(spec.configPath);
|
|
if (!fs3.existsSync(spec.configPath)) {
|
|
fs3.writeFileSync(spec.configPath, spec.mode === "json" ? '{\n "mcpServers": {}\n}\n' : "", "utf-8");
|
|
}
|
|
const out = openFileInSystem(spec.configPath);
|
|
panelLog(out.ok ? "info" : "warn", `[open-config:${spec.clientType}] ${out.message}`);
|
|
return out;
|
|
},
|
|
clearPanelLogs() {
|
|
panelLogs.length = 0;
|
|
return { ok: true };
|
|
}
|
|
};
|
|
var cocosCjsExports = typeof exports === "object" && exports ? exports : null;
|
|
if (cocosCjsExports) {
|
|
cocosCjsExports.load = load;
|
|
cocosCjsExports.unload = unload;
|
|
cocosCjsExports.methods = methods;
|
|
}
|
|
// Annotate the CommonJS export names for ESM import in node:
|
|
0 && (module.exports = {
|
|
load,
|
|
methods,
|
|
unload
|
|
});
|