Editor/Tools/SyntheticPlayerTools.Diagnostics.cs

Editor MCP assistant diagnostics tools. Provides editor-only commands for video recording control, simulated network conditions, voice recording, chat dispatch, runtime play-mode inspection, and running test scenarios, returning JSON tool outcomes.

File AccessNetworking
#nullable enable
using System;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;

namespace Editor.Mcp
{
	public static partial class SboxMcpAssistant
	{
		[McpTool( "pocketknife_video_record" )]
		public static string VideoRecord(
			[Description( "Action: start, stop, status" )] string action,
			[Description( "Destination file name (e.g. 'run.mp4')" )] string fileName = "test_run.mp4" )
		{
			try
			{
				return McpJson.Write( VideoRecordingDriver.Control( action, fileName ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "video.control_failed", "Video control failed.", ex ) );
			}
		}

		[McpTool( "pocketknife_network_condition" )]
		public static string NetworkCondition(
			[Description( "Simulated latency in ms (0-500, -1 to leave unchanged)" )] int fakeLagMs = -1,
			[Description( "Simulated packet loss percentage (0-100, -1 to leave unchanged)" )] float packetLossPercent = -1f,
			[Description( "Network action: none, start_host, disconnect" )] string action = "none" )
		{
			try
			{
				if ( fakeLagMs >= 0 ) ConsoleSystem.SetValue( "net_fakelag", Math.Clamp( fakeLagMs, 0, 500 ).ToString() );
				if ( packetLossPercent >= 0 ) ConsoleSystem.SetValue( "net_fakepacketloss", Math.Clamp( packetLossPercent, 0f, 100f ).ToString() );

				if ( action.Equals( "start_host", StringComparison.OrdinalIgnoreCase ) ) EditorUtility.Network.StartHosting();
				else if ( action.Equals( "disconnect", StringComparison.OrdinalIgnoreCase ) ) EditorUtility.Network.Disconnect();

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					FakeLagMs = fakeLagMs,
					PacketLoss = packetLossPercent,
					IsHosting = EditorUtility.Network.Hosting,
					NetworkActive = EditorUtility.Network.Active
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "network.condition_failed", "Network condition adjustment failed.", ex ) );
			}
		}

		[McpTool( "pocketknife_voice_diagnostic" )]
		public static string VoiceDiagnostic(
			[Description( "Action: status, start_recording, stop_and_save" )] string action,
			[Description( "Destination file path when stopping recording" )] string? wavPath = null )
		{
			try
			{
				var normAction = action.ToLowerInvariant();
				if ( normAction == "start_recording" )
				{
					EditorUtility.VoiceRecording.Start( 44100, 1 );
					return McpJson.Write( ToolOutcome<object>.Ready( new { Action = "start_recording", Status = "Recording started" } ) );
				}
				if ( normAction == "stop_and_save" )
				{
					EditorUtility.VoiceRecording.Stop();
					var path = string.IsNullOrWhiteSpace( wavPath ) ? "recorded_voice_test.wav" : wavPath;
					EditorUtility.VoiceRecording.Save( path );
					return McpJson.Write( ToolOutcome<object>.Ready( new { Action = "stop_and_save", SavedPath = path } ) );
				}

				return McpJson.Write( ToolOutcome<object>.Ready( new { Action = "status", RecordingSupported = true } ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "voice.diagnostic_failed", "Voice diagnostic failed.", ex ) );
			}
		}

		[McpTool( "pocketknife_chat_dispatch" )]
		public static string ChatDispatch(
			[Description( "Chat message text" )] string message,
			[Description( "If true, dispatches as system event; if false, says as local player" )] bool asSystem = true )
		{
			try
			{
				if ( string.IsNullOrWhiteSpace( message ) )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "chat.message_required", "Chat message cannot be empty." ) );
				}

				if ( asSystem )
				{
					var evt = new ChatMessageEvent { Message = message, Sender = null };
					Game.ActiveScene?.RunEvent<IChatEvent>( h => h.OnChatMessage( evt ) );
				}
				else
				{
					Sandbox.Platform.Chat.Say( message );
				}

				return McpJson.Write( ToolOutcome<object>.Ready( new { Message = message, AsSystem = asSystem, Delivered = true } ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "chat.dispatch_failed", "Chat dispatch failed.", ex ) );
			}
		}

		[McpTool( "pocketknife_play_inspect" )]
		public static string PlayInspect(
			[Description( "Query string to find GameObject by name or tag" )] string query,
			[Description( "Specific component name to inspect" )] string? component = null )
		{
			try
			{
				if ( !Game.IsPlaying )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "inspect.playmode_inactive", "Play mode must be active to inspect runtime state." ) );
				}

				var go = FindGameObjectInScene( Game.ActiveScene?.Children, query );
				if ( go == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( new { Query = query }, ToolStatus.Failed, "inspect.not_found", $"GameObject '{query}' not found." ) );
				}

				var components = go.Components.GetAll();
				if ( !string.IsNullOrWhiteSpace( component ) )
				{
					components = components.Where( c => c.GetType().Name.Equals( component, StringComparison.OrdinalIgnoreCase ) );
				}

				var componentList = components.Select( c => new
				{
					Type = c.GetType().Name,
					Enabled = c.Enabled
				} ).ToArray();

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					Name = go.Name,
					Id = go.Id.ToString(),
					Position = go.WorldPosition,
					Components = componentList
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "inspect.failed", "Play inspection failed.", ex ) );
			}
		}

		private static GameObject? FindGameObjectInScene( System.Collections.Generic.IEnumerable<GameObject>? roots, string query )
		{
			if ( roots is null ) return null;
			foreach ( var item in roots )
			{
				if ( item.Id.ToString().Equals( query, StringComparison.OrdinalIgnoreCase ) ||
				     item.Name.Equals( query, StringComparison.OrdinalIgnoreCase ) ||
				     item.Tags.Has( query ) )
				{
					return item;
				}
				var nested = FindGameObjectInScene( item.Children, query );
				if ( nested != null ) return nested;
			}
			return null;
		}

		[McpTool( "pocketknife_run_test_scenario" )]
		public static async Task<string> RunTestScenario(
			[Description( "Human-readable test scenario name" )] string scenarioName,
			[Description( "JSON string containing array of step objects" )] string stepsJson,
			[Description( "Record MP4 video of the scenario run" )] bool recordVideo = false )
		{
			try
			{
				var result = await ScenarioRunner.ExecuteScenarioAsync( scenarioName, stepsJson, recordVideo );
				return McpJson.Write( result );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "scenario.execute_failed", "Scenario execution failed.", ex ) );
			}
		}
	}
}