import { cleanObject } from '../../../utils/serialization/safe-json.js'; import { ITools } from '../../../types/tools/tool-interfaces.js'; import type { GraphArgs, HandlerArgs, AutomationResponse } from '../../../types/handlers/handler-types.js'; import { executeAutomationRequest, normalizePathFields, promoteScalarResultFields } from '../foundation/dispatch/common-handlers.js'; import { TOOL_ACTIONS } from '../../../utils/commands/action-constants.js'; // AutomationResponse imported from types/handler-types.js interface ProcessedGraphArgs extends GraphArgs { subAction?: string; nodeCategory?: string; [key: string]: unknown; } // Blueprint node type aliases: map human-friendly names to K2Node class names // NOTE: These aliases are applied in TS before sending to C++. The C++ plugin has // its own fallback alias map (McpAutomationBridge_BlueprintGraphHandlers.cpp) which // handles unmapped types. Keep these in sync with the C++ map. const BLUEPRINT_NODE_ALIASES: Record = { 'Branch': 'K2Node_IfThenElse', 'IfThenElse': 'K2Node_IfThenElse', 'Sequence': 'K2Node_ExecutionSequence', 'ExecutionSequence': 'K2Node_ExecutionSequence', // ForLoop / ForLoopWithBreak / WhileLoop / ForEachLoop are StandardMacros library // macros (no UK2Node_* class). Do NOT alias them: the bare name must reach the bridge // so TryCreateMacroNode spawns a K2Node_MacroInstance. Aliasing ForEachLoop // to K2Node_ForEachElementInEnum was wrong (the enum iterator). MUST stay in sync with // NODE_ALIASES in blueprint-graph-actions.ts — create_node routes here, add_node there. 'Switch': 'K2Node_SwitchInteger', 'SwitchOnInt': 'K2Node_SwitchInteger', 'SwitchOnString': 'K2Node_SwitchString', 'SwitchOnName': 'K2Node_SwitchName', 'SwitchOnEnum': 'K2Node_SwitchEnum', 'Select': 'K2Node_Select', 'DoOnce': 'K2Node_DoOnce', 'DoN': 'K2Node_DoN', 'FlipFlop': 'K2Node_FlipFlop', 'Gate': 'K2Node_Gate', 'MultiGate': 'K2Node_MultiGate', // 'Delay': 'K2Node_Delay', // Removed: Handled as function call in C++ 'Timeline': 'K2Node_Timeline', 'SpawnActorFromClass': 'K2Node_SpawnActorFromClass', // 'DestroyActor': 'K2Node_DestroyActor', // Removed: Handled as function call in C++ 'GetAllActorsOfClass': 'K2Node_GetAllActorsOfClass' }; // Behavior Tree node type aliases const BT_NODE_ALIASES: Record = { 'Task': { class: 'BTTask_Wait', type: 'task' }, 'Wait': { class: 'BTTask_Wait', type: 'task' }, 'MoveTo': { class: 'BTTask_MoveTo', type: 'task' }, 'PlaySound': { class: 'BTTask_PlaySound', type: 'task' }, 'PlayAnimation': { class: 'BTTask_PlayAnimation', type: 'task' }, 'RunBehavior': { class: 'BTTask_RunBehavior', type: 'task' }, 'MakeNoise': { class: 'BTTask_MakeNoise', type: 'task' }, 'RotateToFaceBBEntry': { class: 'BTTask_RotateToFaceBBEntry', type: 'task' }, 'Decorator': { class: 'BTDecorator_Blackboard', type: 'decorator' }, 'Blackboard': { class: 'BTDecorator_Blackboard', type: 'decorator' }, 'CompareBBEntries': { class: 'BTDecorator_CompareBBEntries', type: 'decorator' }, 'Cooldown': { class: 'BTDecorator_Cooldown', type: 'decorator' }, 'DoesPathExist': { class: 'BTDecorator_DoesPathExist', type: 'decorator' }, 'ForceSuccess': { class: 'BTDecorator_ForceSuccess', type: 'decorator' }, 'KeepInCone': { class: 'BTDecorator_KeepInCone', type: 'decorator' }, 'Loop': { class: 'BTDecorator_Loop', type: 'decorator' }, 'TimeLimit': { class: 'BTDecorator_TimeLimit', type: 'decorator' }, 'Service': { class: 'BTService_DefaultFocus', type: 'service' }, 'DefaultFocus': { class: 'BTService_DefaultFocus', type: 'service' }, 'RunEQS': { class: 'BTService_RunEQS', type: 'service' }, 'Selector': { class: 'BTComposite_Selector', type: 'composite' }, 'SequenceNode': { class: 'BTComposite_Sequence', type: 'composite' }, 'SimpleParallel': { class: 'BTComposite_SimpleParallel', type: 'composite' } }; export async function handleGraphTools(toolName: string, action: string, args: GraphArgs, tools: ITools): Promise> { // Common validation if (!args.assetPath && !args.blueprintPath && !args.systemPath) { // Some actions might not need a path if they operate on "currently open" asset, // but generally we want an asset path. } // Dispatch based on tool name switch (toolName) { case TOOL_ACTIONS.MANAGE_BLUEPRINT: return handleBlueprintGraph(action, args, tools); case 'manage_niagara_graph': return handleNiagaraGraph(action, args, tools); case 'manage_material_graph': return handleMaterialGraph(action, args, tools); case 'manage_behavior_tree': return handleBehaviorTree(action, args, tools); default: throw new Error(`Unknown graph tool: ${toolName}`); } } async function handleBlueprintGraph(action: string, args: GraphArgs, tools: ITools): Promise> { const processedArgs: ProcessedGraphArgs = { ...args, subAction: action }; const processedRecord = processedArgs as Record; // Default graphName if (!processedArgs.graphName) { processedArgs.graphName = 'EventGraph'; } // Map human-friendly node type names to K2Node class names if (processedArgs.nodeType && BLUEPRINT_NODE_ALIASES[processedArgs.nodeType]) { processedArgs.nodeType = BLUEPRINT_NODE_ALIASES[processedArgs.nodeType]; } // Fix Issue 1: Map FunctionCall to CallFunction if (processedArgs.nodeType === 'FunctionCall') { processedArgs.nodeType = 'CallFunction'; } // Fix Issue 2 & 3: Map memberName to specific names based on nodeType if (processedArgs.memberName) { if (processedArgs.nodeType === 'VariableGet' || processedArgs.nodeType === 'VariableSet') { if (!processedArgs.variableName) processedArgs.variableName = processedArgs.memberName; } else if (processedArgs.nodeType === 'Event' || processedArgs.nodeType === 'CustomEvent' || (processedArgs.nodeType && processedArgs.nodeType.startsWith('K2Node_Event'))) { if (!processedArgs.eventName) processedArgs.eventName = processedArgs.memberName; // CustomEvent uses eventName (mapped to CustomFunctionName) or customEventName in some contexts, // but C++ CustomEvent handler uses 'eventName' payload field. } else if (processedArgs.nodeType === 'CallFunction' || processedArgs.nodeType === 'K2Node_CallFunction') { // C++ uses 'memberName' for CallFunction, so this is fine. } } // Fix Issue 5: Map memberClass/componentClass to targetClass for Cast nodes if ((processedArgs.memberClass || processedArgs.componentClass) && (processedArgs.nodeType === 'Cast' || (processedArgs.nodeType && processedArgs.nodeType.startsWith('CastTo')))) { if (!processedArgs.targetClass) processedArgs.targetClass = processedArgs.memberClass || processedArgs.componentClass; } if (processedRecord.nodeGuid !== undefined && !processedArgs.nodeId) { processedArgs.nodeId = processedRecord.nodeGuid as string; } if (processedRecord.sourceNode !== undefined && !processedArgs.fromNodeId) { processedArgs.fromNodeId = processedRecord.sourceNode as string; } if (processedRecord.targetNode !== undefined && !processedArgs.toNodeId) { processedArgs.toNodeId = processedRecord.targetNode as string; } if (processedRecord.sourcePin !== undefined && !processedArgs.fromPinName) { processedArgs.fromPinName = processedRecord.sourcePin as string; } if (processedRecord.targetPin !== undefined && !processedArgs.toPinName) { processedArgs.toPinName = processedRecord.targetPin as string; } if (processedRecord.defaultValue !== undefined && processedRecord.value === undefined) { processedRecord.value = processedRecord.defaultValue; } if (processedRecord.x === undefined && typeof processedRecord.posX === 'number') { processedRecord.x = processedRecord.posX; } if (processedRecord.y === undefined && typeof processedRecord.posY === 'number') { processedRecord.y = processedRecord.posY; } if (action === 'connect_pins') { // Map source if (!processedArgs.fromNodeId && processedArgs.nodeId) { processedArgs.fromNodeId = processedArgs.nodeId; } if (!processedArgs.fromPinName && processedArgs.pinName) { processedArgs.fromPinName = processedArgs.pinName; } // Map target from linkedTo if (!processedArgs.toNodeId && processedArgs.linkedTo) { if (processedArgs.linkedTo.includes('.')) { const parts = processedArgs.linkedTo.split('.'); processedArgs.toNodeId = parts[0]; processedArgs.toPinName = parts.slice(1).join('.'); } } } // Support Node.Pin format for connect_pins (existing logic preserved/enhanced) if (action === 'connect_pins') { if (processedArgs.fromNodeId && processedArgs.fromNodeId.includes('.') && !processedArgs.fromPinName) { const parts = processedArgs.fromNodeId.split('.'); processedArgs.fromNodeId = parts[0]; processedArgs.fromPinName = parts.slice(1).join('.'); } if (processedArgs.toNodeId && processedArgs.toNodeId.includes('.') && !processedArgs.toPinName) { const parts = processedArgs.toNodeId.split('.'); processedArgs.toNodeId = parts[0]; processedArgs.toPinName = parts.slice(1).join('.'); } } const res = await executeAutomationRequest(tools, TOOL_ACTIONS.MANAGE_BLUEPRINT, processedArgs as HandlerArgs, 'Automation bridge not available') as AutomationResponse; return cleanObject(promoteScalarResultFields(res)) as Record; } async function handleNiagaraGraph(action: string, args: GraphArgs, tools: ITools): Promise> { const payload: ProcessedGraphArgs = { ...args, subAction: action }; // Normalize parameter aliases - tests may use 'system' instead of 'systemPath' or 'assetPath' if (!payload.systemPath && args.system) { payload.systemPath = args.system as string; } if (!payload.assetPath && payload.systemPath) { payload.assetPath = payload.systemPath as string; } if (!payload.assetPath && args.system) { payload.assetPath = args.system as string; } const res = await executeAutomationRequest(tools, 'manage_niagara_graph', payload as HandlerArgs, 'Automation bridge not available') as AutomationResponse; return cleanObject(promoteScalarResultFields(res)) as Record; } async function handleMaterialGraph(action: string, args: GraphArgs, tools: ITools): Promise> { const payload: ProcessedGraphArgs = { ...args, subAction: action }; // Map blueprint-style parameters to material graph parameters if (action === 'connect_pins' || action === 'connect_nodes') { if (payload.fromNodeId && !payload.sourceNodeId) { payload.sourceNodeId = payload.fromNodeId; } if (payload.toNodeId && !payload.targetNodeId) { if (typeof payload.toNodeId === 'string') { payload.targetNodeId = payload.toNodeId.toLowerCase() === 'root' ? 'Main' : payload.toNodeId; } } const targetInput = payload.toPin ?? payload.targetPin; if (targetInput && !payload.inputName) { if (typeof targetInput === 'string') { payload.inputName = targetInput.replace(/\s+/g, ''); } } } const res = await executeAutomationRequest(tools, 'manage_material_graph', payload as HandlerArgs, 'Automation bridge not available') as AutomationResponse; return cleanObject(promoteScalarResultFields(res)) as Record; } async function handleBehaviorTree(action: string, args: GraphArgs, tools: ITools): Promise> { const processedArgs = normalizePathFields({ ...args, subAction: action }, [ 'assetPath', 'behaviorTreePath', 'blackboardPath', 'savePath', 'path' ]) as ProcessedGraphArgs; // Map human-friendly node type names to BT class names if (processedArgs.nodeType && BT_NODE_ALIASES[processedArgs.nodeType]) { const alias = BT_NODE_ALIASES[processedArgs.nodeType]; processedArgs.nodeType = alias.class; // Set nodeCategory if not already set if (!processedArgs.nodeCategory) { processedArgs.nodeCategory = alias.type; } } // For add_subnode, alias nodeClass through the same map (callers pass short names like "Cooldown") if (action === 'add_subnode' && typeof processedArgs.nodeClass === 'string' && BT_NODE_ALIASES[processedArgs.nodeClass]) { const alias = BT_NODE_ALIASES[processedArgs.nodeClass]; processedArgs.nodeClass = alias.class; } const res = await executeAutomationRequest(tools, 'manage_behavior_tree', processedArgs as HandlerArgs, 'Automation bridge not available') as AutomationResponse; return cleanObject(promoteScalarResultFields(res)) as Record; }