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.
#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 ) );
}
}
}
}