Editor/Tools/AutonomousAgentTools.CombatAndPhysics.cs

Editor-side tools for the MCP assistant. Provides scene snapshot/diffing, detailed physics inspection, simulated local combat between two controllers, UI assertions, and multi-controller input simulation for testing and debugging within the editor/play scene.

ReflectionFile Access
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Sandbox;
using Sandbox.UI;
using Component = Sandbox.Component;

namespace Editor.Mcp
{
	public static partial class SboxMcpAssistant
	{
		private static readonly Dictionary<string, Dictionary<Guid, SceneObjectSnapshot>> SceneSnapshots = new( StringComparer.OrdinalIgnoreCase );

		private sealed class SceneObjectSnapshot
		{
			public Guid Id { get; init; }
			public string Name { get; init; } = "";
			public Guid ParentId { get; init; }
			public bool Enabled { get; init; }
			public Vector3 Position { get; init; }
			public Angles Angles { get; init; }
			public Vector3 Scale { get; init; }
			public Dictionary<string, bool> Components { get; init; } = new();
		}

		/// <summary>
		/// Captures or compares scene snapshots to track GameObject creations, deletions, transform displacements,
		/// and component modifications between simulation steps or test actions.
		/// </summary>
		private static Scene? ResolveActiveScene()
		{
			if ( Game.ActiveScene != null ) return Game.ActiveScene;
			var session = EditorRuntime.GetStaticProperty( "Editor.SceneEditorSession", "Active" );
			if ( session != null )
			{
				return EditorRuntime.GetMember( session, "Scene" ) as Scene;
			}
			return null;
		}


		[McpTool( "pocketknife_scene_diff" )]
		public static string SceneDiff(
			[Description( "Action: 'snapshot' to capture baseline, 'compare' to diff against baseline, 'clear' to reset" )] string action = "compare",
			[Description( "Snapshot tag identifier (default: 'latest')" )] string tag = "latest",
			[Description( "Minimum position change threshold in units to report displacement" )] float positionThreshold = 0.05f,
			[Description( "Minimum rotation change threshold in degrees to report orientation delta" )] float rotationThreshold = 0.5f )
		{
			try
			{
				var scene = ResolveActiveScene();
				if ( scene == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "scene.not_found", "No active scene found." ) );
				}


				var normAction = action.ToLowerInvariant().Trim();
				var normTag = string.IsNullOrWhiteSpace( tag ) ? "latest" : tag.Trim();

				if ( normAction == "clear" )
				{
					SceneSnapshots.Remove( normTag );
					return McpJson.Write( ToolOutcome<object>.Ready( new { Action = "clear", Tag = normTag, Status = "Snapshot cleared" } ) );
				}

				if ( normAction == "snapshot" )
				{
					var snapshot = CaptureSceneState( scene );
					SceneSnapshots[normTag] = snapshot;
					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = "snapshot",
						Tag = normTag,
						GameObjectCount = snapshot.Count,
						Timestamp = DateTime.UtcNow
					} ) );
				}

				if ( normAction == "compare" )
				{
					if ( !SceneSnapshots.TryGetValue( normTag, out var baseline ) )
					{
						// Auto-capture baseline if none exists
						baseline = CaptureSceneState( scene );
						SceneSnapshots[normTag] = baseline;
						return McpJson.Write( ToolOutcome<object>.Ready( new
						{
							Action = "compare",
							Tag = normTag,
							Status = "Baseline created as none existed previously",
							GameObjectCount = baseline.Count
						} ) );
					}

					var current = CaptureSceneState( scene );
					var spawned = new List<object>();
					var destroyed = new List<object>();
					var moved = new List<object>();
					var modified = new List<object>();

					// Detect spawned
					foreach ( var kvp in current )
					{
						if ( !baseline.ContainsKey( kvp.Key ) )
						{
							spawned.Add( new { Id = kvp.Key.ToString(), kvp.Value.Name, kvp.Value.Position } );
						}
					}

					// Detect destroyed
					foreach ( var kvp in baseline )
					{
						if ( !current.ContainsKey( kvp.Key ) )
						{
							destroyed.Add( new { Id = kvp.Key.ToString(), kvp.Value.Name, kvp.Value.Position } );
						}
					}

					// Detect moved and modified
					foreach ( var kvp in current )
					{
						if ( baseline.TryGetValue( kvp.Key, out var baseObj ) )
						{
							var currObj = kvp.Value;
							var posDelta = currObj.Position - baseObj.Position;
							var displacement = posDelta.Length;
							var angleDelta = new Angles(
								Math.Abs( currObj.Angles.pitch - baseObj.Angles.pitch ),
								Math.Abs( currObj.Angles.yaw - baseObj.Angles.yaw ),
								Math.Abs( currObj.Angles.roll - baseObj.Angles.roll ) );

							var isMoved = displacement >= positionThreshold;
							var isRotated = angleDelta.pitch >= rotationThreshold || angleDelta.yaw >= rotationThreshold || angleDelta.roll >= rotationThreshold;

							if ( isMoved || isRotated )
							{
								moved.Add( new
								{
									Id = kvp.Key.ToString(),
									currObj.Name,
									Displacement = displacement,
									PositionDelta = posDelta,
									CurrentPosition = currObj.Position,
									BaselinePosition = baseObj.Position,
									Rotated = isRotated,
									CurrentAngles = currObj.Angles
								} );
							}

							// Check component changes or enabled state
							if ( currObj.Enabled != baseObj.Enabled || !currObj.Components.Keys.SequenceEqual( baseObj.Components.Keys ) )
							{
								modified.Add( new
								{
									Id = kvp.Key.ToString(),
									currObj.Name,
									EnabledDelta = currObj.Enabled != baseObj.Enabled ? new { Before = baseObj.Enabled, After = currObj.Enabled } : null,
									AddedComponents = currObj.Components.Keys.Except( baseObj.Components.Keys ).ToArray(),
									RemovedComponents = baseObj.Components.Keys.Except( currObj.Components.Keys ).ToArray()
								} );
							}
						}
					}

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Tag = normTag,
						BaselineCount = baseline.Count,
						CurrentCount = current.Count,
						SpawnedCount = spawned.Count,
						DestroyedCount = destroyed.Count,
						MovedCount = moved.Count,
						ModifiedCount = modified.Count,
						Spawned = spawned,
						Destroyed = destroyed,
						Moved = moved,
						Modified = modified
					} ) );
				}

				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "diff.action_invalid", $"Unsupported action '{action}'. Use snapshot, compare, or clear." ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "diff.failed", "Scene diff failed.", ex ) );
			}
		}

		private static Dictionary<Guid, SceneObjectSnapshot> CaptureSceneState( Scene scene )
		{
			var dict = new Dictionary<Guid, SceneObjectSnapshot>();
			var allObjects = scene.GetAllObjects( false );
			foreach ( var go in allObjects )
			{
				if ( !go.IsValid() ) continue;
				var compDict = new Dictionary<string, bool>();
				foreach ( var c in go.Components.GetAll<Component>() )
				{
					if ( c.IsValid() )
					{
						compDict[c.GetType().Name] = c.Enabled;
					}
				}

				dict[go.Id] = new SceneObjectSnapshot
				{
					Id = go.Id,
					Name = go.Name,
					ParentId = go.Parent?.Id ?? Guid.Empty,
					Enabled = go.Enabled,
					Position = go.WorldPosition,
					Angles = go.WorldRotation.Angles(),
					Scale = go.WorldScale,
					Components = compDict
				};
			}
			return dict;
		}

		/// <summary>
		/// Deep Rubikon physics state inspection: inspects rigidbodies, velocities, mass, center of mass,
		/// connected joints (hinge, ball-socket, fixed), break status, ragdoll bone hierarchy, and contact points.
		/// </summary>
		[McpTool( "pocketknife_physics_query" )]
		public static string PhysicsQuery(
			[Description( "Target GameObject name or GUID" )] string target,
			[Description( "Include physics joint inspection (Hinge, BallSocket, Fixed, etc.)" )] bool includeJoints = true,
			[Description( "Include ragdoll bone hierarchy inspection if active" )] bool includeRagdollBones = true,
			[Description( "Include contact and proximity collisions query" )] bool includeContacts = true )
		{
			try
			{
				var scene = ResolveActiveScene();
				if ( scene == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "scene.inactive", "Active scene not found." ) );
				}


				var go = scene.Directory.FindByGuid( Guid.TryParse( target, out var guid ) ? guid : Guid.Empty )
					?? scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, target, StringComparison.OrdinalIgnoreCase ) );

				if ( go == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( new { Target = target }, ToolStatus.Failed, "target.not_found", $"GameObject '{target}' not found." ) );
				}

				var rb = go.GetComponent<Rigidbody>();
				object? rbData = null;
				if ( rb != null )
				{
					rbData = new
					{
						rb.Mass,
						rb.Velocity,
						rb.AngularVelocity,
						Speed = rb.Velocity.Length,
						AngularSpeed = rb.AngularVelocity.Length,
						IsStatic = rb.MotionEnabled == false,
						IsSleeping = rb.PhysicsBody?.Sleeping ?? false,
						WorldCenterOfMass = rb.PhysicsBody?.MassCenter ?? go.WorldPosition,
						LocalCenterOfMass = rb.PhysicsBody?.LocalMassCenter ?? Vector3.Zero
					};
				}

				// Inspect joints
				var jointsData = new List<object>();
				if ( includeJoints )
				{
					var jointComps = go.Components.GetAll<Component>()
						.Concat( go.GetAllObjects( false ).SelectMany( c => c.Components.GetAll<Component>() ) )
						.Where( c => c.GetType().Name.Contains( "Joint", StringComparison.OrdinalIgnoreCase ) )
						.Distinct();

					foreach ( var j in jointComps )
					{
						var jt = j.GetType();
						jointsData.Add( new
						{
							Type = jt.Name,
							GameObject = j.GameObject?.Name,
							Id = j.Id.ToString(),
							j.Enabled,
							Body1 = SafeReadProp( j, "Body1" ) ?? SafeReadProp( j, "Point1" ),
							Body2 = SafeReadProp( j, "Body2" ) ?? SafeReadProp( j, "Point2" ),
							IsBroken = SafeReadProp( j, "IsBroken" ) ?? "false",
							BreakForce = SafeReadProp( j, "BreakForce" ) ?? SafeReadProp( j, "MaxForce" ),
							BreakTorque = SafeReadProp( j, "BreakTorque" ) ?? SafeReadProp( j, "MaxTorque" )
						} );
					}
				}

				// Inspect ragdoll bones
				var ragdollData = new List<object>();
				if ( includeRagdollBones )
				{
					var boneRigidbodies = go.GetAllObjects( false )
						.Select( child => new { Child = child, Rb = child.GetComponent<Rigidbody>() } )
						.Where( x => x.Rb != null )
						.ToArray();

					foreach ( var b in boneRigidbodies )
					{
						ragdollData.Add( new
						{
							Bone = b.Child.Name,
							Position = b.Child.WorldPosition,
							Angles = b.Child.WorldRotation.Angles(),
							Velocity = b.Rb!.Velocity,
							Speed = b.Rb.Velocity.Length,
							b.Rb.AngularVelocity,
							b.Rb.Mass
						} );
					}
				}

				// Inspect contacts / proximity collisions
				var contactsData = new List<object>();
				if ( includeContacts && scene.PhysicsWorld != null )
				{
					var center = go.WorldPosition;
					var traceRadius = 50f;
					var tr = scene.PhysicsWorld.Trace
						.Sphere( traceRadius, center, center + Vector3.Down * 5f )
						.WithoutTags( "trigger" )
						.Run();

					if ( tr.Hit )
					{
						contactsData.Add( new
						{
							HitObject = tr.Body?.GameObject?.Name,
							tr.HitPosition,
							tr.Normal,
							tr.Distance,
							Surface = tr.Surface?.ResourceName
						} );
					}

				}

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					Target = go.Name,
					Id = go.Id.ToString(),
					WorldPosition = go.WorldPosition,
					WorldRotation = go.WorldRotation.Angles(),
					HasRigidbody = rb != null,
					Rigidbody = rbData,
					JointCount = jointsData.Count,
					Joints = jointsData,
					RagdollBoneCount = ragdollData.Count,
					RagdollBones = ragdollData,
					Contacts = contactsData
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "physics_query.failed", "Physics query failed.", ex ) );
			}
		}

		private static string? SafeReadProp( object target, string propName )
		{
			try
			{
				var p = target.GetType().GetProperty( propName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance );
				return p?.GetValue( target )?.ToString();
			}
			catch { return null; }
		}

		/// <summary>
		/// Simulates a 1v1 multi-controller combat exchange on a single client using local PlayerScopes.
		/// Drives player 1 actions (attack, punch, grab) and player 2 actions (evade, jump, counter),
		/// steps physics simulation, and measures displacement, knockback velocity, and knockout/ragdoll response.
		/// </summary>
		[McpTool( "pocketknife_multi_combat_simulate" )]
		public static async Task<string> MultiCombatSimulate(
			[Description( "Action for Player 1 (e.g. 'Attack1', 'Forward', 'Jump')" )] string player1Action,
			[Description( "Action for Player 2 (e.g. 'Jump', 'Duck', 'Attack1', 'None')" )] string player2Action = "None",
			[Description( "Target GameObject name or Id for Player 1" )] string? player1Target = null,
			[Description( "Target GameObject name or Id for Player 2" )] string? player2Target = null,
			[Description( "Duration of the combat pulse in milliseconds (50-2000)" )] int durationMs = 300,
			[Description( "Number of physics frames to step during simulation" )] int stepFrames = 18 )
		{
			try
			{
				if ( !Game.IsPlaying )
				{
					EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayStart" );
					await Task.Delay( 300 );
				}

				var scene = Game.ActiveScene;
				if ( scene == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "scene.inactive", "Active play scene not found." ) );
				}

				GameObject? p1Go = null;
				GameObject? p2Go = null;

				if ( !string.IsNullOrWhiteSpace( player1Target ) )
				{
					p1Go = scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, player1Target, StringComparison.OrdinalIgnoreCase ) );
				}
				if ( !string.IsNullOrWhiteSpace( player2Target ) )
				{
					p2Go = scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, player2Target, StringComparison.OrdinalIgnoreCase ) );
				}

				var p1StartPos = p1Go?.WorldPosition ?? Vector3.Zero;
				var p2StartPos = p2Go?.WorldPosition ?? Vector3.Zero;
				var p1StartHealth = ReadHealth( p1Go );
				var p2StartHealth = ReadHealth( p2Go );

				// Dispatch Player 1 input
				using ( Sandbox.Input.PlayerScope( (Sandbox.Input.PlayerIndex)0 ) )
				{
					if ( !string.IsNullOrWhiteSpace( player1Action ) && !string.Equals( player1Action, "None", StringComparison.OrdinalIgnoreCase ) )
					{
						await SyntheticInputDriver.SimulateInputAsync( player1Action, "down", durationMs );
					}
				}

				// Dispatch Player 2 input
				using ( Sandbox.Input.PlayerScope( (Sandbox.Input.PlayerIndex)1 ) )
				{
					if ( !string.IsNullOrWhiteSpace( player2Action ) && !string.Equals( player2Action, "None", StringComparison.OrdinalIgnoreCase ) )
					{
						await SyntheticInputDriver.SimulateInputAsync( player2Action, "down", durationMs );
					}
				}

				// Step simulation
				var boundedFrames = Math.Clamp( stepFrames, 1, 120 );
				for ( int i = 0; i < boundedFrames; i++ )
				{
					scene.PhysicsWorld?.Step( 1.0f / 60.0f );
					await Task.Delay( 16 );
				}

				// Release inputs
				using ( Sandbox.Input.PlayerScope( (Sandbox.Input.PlayerIndex)0 ) )
				{
					if ( !string.IsNullOrWhiteSpace( player1Action ) && !string.Equals( player1Action, "None", StringComparison.OrdinalIgnoreCase ) )
					{
						await SyntheticInputDriver.SimulateInputAsync( player1Action, "up", 0 );
					}
				}

				using ( Sandbox.Input.PlayerScope( (Sandbox.Input.PlayerIndex)1 ) )
				{
					if ( !string.IsNullOrWhiteSpace( player2Action ) && !string.Equals( player2Action, "None", StringComparison.OrdinalIgnoreCase ) )
					{
						await SyntheticInputDriver.SimulateInputAsync( player2Action, "up", 0 );
					}
				}

				await Task.Delay( 50 );

				// Measure post-combat outcomes
				var p1EndPos = p1Go?.WorldPosition ?? Vector3.Zero;
				var p2EndPos = p2Go?.WorldPosition ?? Vector3.Zero;
				var p1Displacement = (p1EndPos - p1StartPos).Length;
				var p2Displacement = (p2EndPos - p2StartPos).Length;
				var p1Velocity = p1Go?.GetComponent<Rigidbody>()?.Velocity ?? Vector3.Zero;
				var p2Velocity = p2Go?.GetComponent<Rigidbody>()?.Velocity ?? Vector3.Zero;
				var p1EndHealth = ReadHealth( p1Go );
				var p2EndHealth = ReadHealth( p2Go );

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					CombatSimulated = true,
					DurationMs = durationMs,
					FramesStepped = boundedFrames,
					Player1 = new
					{
						Target = p1Go?.Name ?? "Player1",
						Action = player1Action,
						Displacement = p1Displacement,
						Velocity = p1Velocity,
						Speed = p1Velocity.Length,
						HealthBefore = p1StartHealth,
						HealthAfter = p1EndHealth,
						HealthDelta = (p1StartHealth ?? 0) - (p1EndHealth ?? 0)
					},
					Player2 = new
					{
						Target = p2Go?.Name ?? "Player2",
						Action = player2Action,
						Displacement = p2Displacement,
						Velocity = p2Velocity,
						Speed = p2Velocity.Length,
						HealthBefore = p2StartHealth,
						HealthAfter = p2EndHealth,
						HealthDelta = (p2StartHealth ?? 0) - (p2EndHealth ?? 0)
					},
					KnockbackOccurred = p2Displacement > 0.5f || p1Displacement > 0.5f
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "combat_simulate.failed", "Multiplayer combat simulation failed.", ex ) );
			}
		}

		private static float? ReadHealth( GameObject? go )
		{
			if ( go == null || !go.IsValid() ) return null;
			foreach ( var c in go.Components.GetAll<Component>() )
			{
				var p = c.GetType().GetProperty( "Health", BindingFlags.Public | BindingFlags.Instance );
				if ( p != null && p.CanRead )
				{
					var val = p.GetValue( c );
					if ( val is float f ) return f;
					if ( val is int i ) return i;
					if ( val != null && float.TryParse( val.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed ) ) return parsed;
				}
			}
			return null;
		}

		/// <summary>
		/// Asserts runtime Razor UI state: locates panel by CSS selector or text, verifies visibility,
		/// inner text matches, CSS class presence, and element dimensions.
		/// </summary>
		[McpTool( "pocketknife_ui_assert" )]
		public static string UiAssert(
			[Description( "CSS selector (e.g. '.mainmenu button.start') or text search" )] string selector,
			[Description( "Optional text that the panel or its children must contain" )] string? expectedText = null,
			[Description( "Optional CSS class that the panel must contain" )] string? expectedClass = null,
			[Description( "Assert whether panel should be visible (true or false)" )] bool? shouldBeVisible = null,
			[Description( "Minimum expected width in pixels" )] float? minWidth = null,
			[Description( "Minimum expected height in pixels" )] float? minHeight = null )
		{
			try
			{
				if ( !Game.IsPlaying )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "ui.playmode_inactive", "Play mode must be active to assert UI state." ) );
				}

				var rootPanels = EditorUtility.GetRootPanels();
				if ( rootPanels == null || rootPanels.Count == 0 )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "ui.no_root_panels", "No active root panels in current scene." ) );
				}

				Panel? targetPanel = null;

				// Try selector resolution first
				foreach ( var rootObj in rootPanels )
				{
					if ( rootObj is not Panel root || !root.IsValid() ) continue;
					targetPanel = FindPanelRecursive( root, selector );
					if ( targetPanel != null ) break;
				}

				// If not found by CSS, check by text content
				if ( targetPanel == null )
				{
					foreach ( var rootObj in rootPanels )
					{
						if ( rootObj is not Panel root || !root.IsValid() ) continue;
						targetPanel = root.Descendants.FirstOrDefault( p =>
							p.IsValid() && (
								(p is Sandbox.UI.Label l && l.Text?.Contains( selector, StringComparison.OrdinalIgnoreCase ) == true) ||
								p.ElementName.Contains( selector, StringComparison.OrdinalIgnoreCase ) ||
								p.Id?.Contains( selector, StringComparison.OrdinalIgnoreCase ) == true
							) );
						if ( targetPanel != null ) break;
					}
				}

				if ( targetPanel == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( new { Selector = selector }, ToolStatus.Failed, "ui.panel_not_found", $"UI Panel matching '{selector}' was not found." ) );
				}

				var failures = new List<string>();
				var actualText = ExtractPanelText( targetPanel );

				if ( shouldBeVisible.HasValue && targetPanel.IsVisible != shouldBeVisible.Value )
				{
					failures.Add( $"Visibility mismatch: expected {shouldBeVisible.Value}, got {targetPanel.IsVisible}" );
				}

				if ( !string.IsNullOrWhiteSpace( expectedText ) && !actualText.Contains( expectedText, StringComparison.OrdinalIgnoreCase ) )
				{
					failures.Add( $"Text mismatch: expected to contain '{expectedText}', but actual was '{actualText}'" );
				}

				if ( !string.IsNullOrWhiteSpace( expectedClass ) && !targetPanel.HasClass( expectedClass ) )
				{
					failures.Add( $"Class mismatch: panel does not have CSS class '{expectedClass}'" );
				}

				var rect = targetPanel.Box.Rect;
				if ( minWidth.HasValue && rect.Width < minWidth.Value )
				{
					failures.Add( $"Width mismatch: expected >= {minWidth.Value}px, actual was {rect.Width}px" );
				}

				if ( minHeight.HasValue && rect.Height < minHeight.Value )
				{
					failures.Add( $"Height mismatch: expected >= {minHeight.Value}px, actual was {rect.Height}px" );
				}

				var passed = failures.Count == 0;
				var result = new
				{
					Passed = passed,
					Selector = selector,
					PanelElement = targetPanel.ElementName,
					PanelId = targetPanel.Id,
					IsVisible = targetPanel.IsVisible,
					ActualText = actualText,
					Rect = new { rect.Left, rect.Top, rect.Width, rect.Height },
					Failures = failures
				};

				return passed
					? McpJson.Write( ToolOutcome<object>.Ready( result ) )
					: McpJson.Write( ToolOutcome<object>.Failure( result, ToolStatus.Failed, "ui.assertion_failed", string.Join( "; ", failures ) ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "ui_assert.failed", "UI assertion failed.", ex ) );
			}
		}

		private static Panel? FindPanelRecursive( Panel root, string selector )
		{
			if ( !root.IsValid() ) return null;
			if ( string.Equals( root.ElementName, selector, StringComparison.OrdinalIgnoreCase ) ||
				 string.Equals( root.Id, selector, StringComparison.OrdinalIgnoreCase ) ||
				 root.HasClass( selector.TrimStart( '.' ) ) )
			{
				return root;
			}

			foreach ( var child in root.Descendants )
			{
				if ( !child.IsValid() ) continue;
				if ( string.Equals( child.ElementName, selector, StringComparison.OrdinalIgnoreCase ) ||
					 string.Equals( child.Id, selector, StringComparison.OrdinalIgnoreCase ) ||
					 child.HasClass( selector.TrimStart( '.' ) ) )
				{
					return child;
				}
			}
			return null;
		}

		private static string ExtractPanelText( Panel panel )
		{
			if ( panel is Sandbox.UI.Label label && !string.IsNullOrEmpty( label.Text ) ) return label.Text;
			if ( panel is TextEntry entry && !string.IsNullOrEmpty( entry.Text ) ) return entry.Text;
			var childTexts = panel.Descendants.OfType<Sandbox.UI.Label>().Select( l => l.Text ).Where( t => !string.IsNullOrEmpty( t ) );
			return string.Join( " ", childTexts );
		}


		/// <summary>
		/// Dispatches simultaneous multi-controller inputs for couch co-op / local multiplayer testing.
		/// Supports any number of players (P0, P1, P2, P3...) with actions, analog move, and analog look.
		/// </summary>
		[McpTool( "pocketknife_multicontroller_input" )]
		public static async Task<string> MultiControllerInput(
			[Description( "JSON array of player inputs: '[{\"player\": 0, \"action\": \"Forward\", \"move\": \"0,1\"}, {\"player\": 1, \"action\": \"Jump\"}]'" )] string inputsJson,
			[Description( "Duration of the input pulse in milliseconds (20-5000)" )] int durationMs = 150 )
		{
			try
			{
				if ( !Game.IsPlaying )
				{
					EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayStart" );
					await Task.Delay( 300 );
				}

				if ( string.IsNullOrWhiteSpace( inputsJson ) )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "input.json_required", "inputsJson is required." ) );
				}

				JsonNode? node = null;
				try
				{
					node = JsonNode.Parse( inputsJson );
				}
				catch
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "input.invalid_json", "inputsJson must be a valid JSON array string." ) );
				}

				if ( node is not JsonArray array || array.Count == 0 )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "input.array_required", "inputsJson must be a non-empty JSON array." ) );
				}

				var boundedDuration = Math.Clamp( durationMs, 20, 5000 );
				var applied = new List<object>();

				// Apply inputs for each player
				foreach ( var item in array )
				{
					if ( item is not JsonObject obj ) continue;
					var playerIndex = obj["player"]?.GetValue<int>() ?? 0;
					var action = obj["action"]?.GetValue<string>();
					var moveStr = obj["move"]?.GetValue<string>();
					var lookStr = obj["look"]?.GetValue<string>();

					using ( Sandbox.Input.PlayerScope( (Sandbox.Input.PlayerIndex)playerIndex ) )
					{
						if ( !string.IsNullOrWhiteSpace( action ) )
						{
							Sandbox.Input.SetAction( action, true );
						}
						if ( !string.IsNullOrWhiteSpace( moveStr ) )
						{
							var parts = moveStr.Split( ',' );
							if ( parts.Length == 2 && float.TryParse( parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var mx ) && float.TryParse( parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var my ) )
							{
								Sandbox.Input.AnalogMove = new Vector3( mx, my, 0 );
							}
						}
						if ( !string.IsNullOrWhiteSpace( lookStr ) )
						{
							var parts = lookStr.Split( ',' );
							if ( parts.Length == 2 && float.TryParse( parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var lx ) && float.TryParse( parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var ly ) )
							{
								Sandbox.Input.AnalogLook = new Angles( lx, ly, 0 );
							}
						}
					}

					applied.Add( new { PlayerIndex = playerIndex, Action = action, Move = moveStr, Look = lookStr } );
				}

				// Wait pulse duration
				await Task.Delay( boundedDuration );

				// Release inputs for each player
				foreach ( var item in array )
				{
					if ( item is not JsonObject obj ) continue;
					var playerIndex = obj["player"]?.GetValue<int>() ?? 0;
					var action = obj["action"]?.GetValue<string>();

					using ( Sandbox.Input.PlayerScope( (Sandbox.Input.PlayerIndex)playerIndex ) )
					{
						if ( !string.IsNullOrWhiteSpace( action ) )
						{
							Sandbox.Input.SetAction( action, false );
						}
						Sandbox.Input.AnalogMove = Vector3.Zero;
						Sandbox.Input.AnalogLook = Angles.Zero;
					}
				}

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					SimulatedPlayers = applied.Count,
					DurationMs = boundedDuration,
					Commands = applied,
					Success = true
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "multicontroller.failed", "Multi-controller input simulation failed.", ex ) );
			}
		}
	}
}