Editor/Tools/WorkflowTools.ServicesAndSocial.cs

Editor MCP assistant tools for services and social features. Provides commands to manage Cloud Database tables (create/get/list records, group info), inspect and unlock Achievements, query Steam friends via reflection, and run Voice component diagnostics in the active scene.

ReflectionNetworkingFile Access
#nullable enable
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Sandbox;
using Sandbox.Services;

namespace Editor.Mcp
{
	public static partial class SboxMcpAssistant
	{
		/// <summary>
		/// Manages and queries modern s&amp;box Facepunch Cloud Database tables (Sandbox.Services.Database)
		/// for player inventories, match persistence, UGC, and custom cloud save states.
		/// </summary>
		[McpTool( "pocketknife_database_manage" )]
		public static async Task<string> DatabaseManage(
			[Description( "Action: 'get_table', 'create_record', 'get_record', 'list_records', 'list_owned', 'group_info'" )] string action,
			[Description( "Database table name (e.g. 'player_stats', 'inventory', 'match_logs')" )] string table = "player_stats",
			[Description( "Record key (for create_record)" )] string? key = null,
			[Description( "Record string or JSON payload value" )] string? value = null,
			[Description( "Grouping identifier (e.g. 'global', 'season_1', 'user')" )] string group = "global",
			[Description( "Whether record is publicly readable" )] bool isPublic = true,
			[Description( "Record ID (for get_record)" )] long id = 0,
			[Description( "Pagination cursor (for list_records)" )] long cursor = 0,
			[Description( "Page size (1-100, default 20)" )] int pageSize = 20 )
		{
			try
			{
				var normAction = action.ToLowerInvariant().Trim();
				var dbTable = Database.GetTable( table );
				if ( dbTable == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( new { Table = table }, ToolStatus.Failed, "database.table_unavailable", $"Database table '{table}' is unavailable." ) );
				}

				if ( normAction == "get_table" || normAction == "status" )
				{
					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = normAction,
						dbTable.Name,
						dbTable.Package
					} ) );
				}

				if ( normAction == "create_record" )
				{
					if ( string.IsNullOrWhiteSpace( key ) )
					{
						return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "database.missing_key", "Key is required to create a record." ) );
					}

					var record = await dbTable.Create( key, value ?? string.Empty, group, isPublic );
					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = "create_record",
						Table = table,
						RecordId = record.Id,
						record.Key,
						record.Value,
						record.Group,
						record.IsPublic,
						record.CreatedAt,
						record.UpdatedAt,
						record.Revision
					} ) );
				}

				if ( normAction == "get_record" )
				{
					var record = await dbTable.Get( id );
					if ( record == null )
					{
						return McpJson.Write( ToolOutcome<object>.Failure( new { Id = id }, ToolStatus.Degraded, "database.not_found", $"Record #{id} not found." ) );
					}

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = "get_record",
						Table = table,
						RecordId = record.Id,
						record.Key,
						record.Value,
						record.Group,
						record.IsPublic,
						record.CreatedAt,
						record.UpdatedAt,
						record.Revision
					} ) );
				}

				if ( normAction == "list_records" )
				{
					var boundedPageSize = Math.Clamp( pageSize, 1, 100 );
					var page = await dbTable.List( group, cursor, boundedPageSize );
					var records = page.Records?.Select( r => new
					{
						RecordId = r.Id,
						r.Key,
						r.Value,
						r.Group,
						r.IsPublic,
						r.CreatedAt,
						r.UpdatedAt,
						r.Revision
					} ).ToArray() ?? Array.Empty<object>();

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = "list_records",
						Table = table,
						Group = group,
						NextCursor = page.NextCursor,
						ReturnedCount = records.Length,
						Records = records
					} ) );
				}

				if ( normAction == "list_owned" )
				{
					var boundedPageSize = Math.Clamp( pageSize, 1, 100 );
					var page = await dbTable.ListOwned( group, cursor, boundedPageSize );
					var records = page.Records?.Select( r => new
					{
						RecordId = r.Id,
						r.Key,
						r.Value,
						r.Group,
						r.IsPublic,
						r.CreatedAt,
						r.UpdatedAt,
						r.Revision
					} ).ToArray() ?? Array.Empty<object>();

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = "list_owned",
						Table = table,
						Group = group,
						NextCursor = page.NextCursor,
						ReturnedCount = records.Length,
						Records = records
					} ) );
				}

				if ( normAction == "group_info" )
				{
					var groups = new[] { group };
					var infoMap = await dbTable.GetPublicGroupInfo( groups );
					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = "group_info",
						Table = table,
						Groups = infoMap
					} ) );
				}

				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "database.invalid_action", $"Unsupported database action '{action}'. Use 'status', 'get_table', 'create_record', 'get_record', 'list_records', 'list_owned', or 'group_info'." ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "database.operation_failed", "Database operation failed.", ex ) );
			}
		}

		/// <summary>
		/// Inspects and unlocks s&amp;box achievements (Sandbox.Services.Achievements) to test reward logic,
		/// milestones, score progression, and trophy pipelines during gameplay simulation.
		/// </summary>
		[McpTool( "pocketknife_achievement_manage" )]
		public static string AchievementManage(
			[Description( "Action: 'list', 'unlock', 'status'" )] string action = "list",
			[Description( "Achievement identifier name (e.g. 'first_blood', 'win_10_matches')" )] string? name = null )
		{
			try
			{
				var normAction = action.ToLowerInvariant().Trim();
				var allAchievements = Achievements.All?.ToArray() ?? Array.Empty<Achievement>();

				if ( normAction == "list" )
				{
					var list = allAchievements.Select( a => new
					{
						a.Name,
						a.Title,
						a.Description,
						a.IsUnlocked,
						a.Score,
						a.CurrentValue,
						a.HasProgression,
						a.ProgressionFraction,
						a.GlobalFraction
					} ).ToArray();

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						TotalCount = list.Length,
						UnlockedCount = list.Count( a => a.IsUnlocked ),
						Achievements = list
					} ) );
				}

				if ( normAction == "unlock" )
				{
					if ( string.IsNullOrWhiteSpace( name ) )
					{
						return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "achievement.missing_name", "Achievement name is required to unlock." ) );
					}

					Achievements.Unlock( name.Trim() );

					var target = allAchievements.FirstOrDefault( a => string.Equals( a.Name, name.Trim(), StringComparison.OrdinalIgnoreCase ) );
					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = "unlock",
						Target = name,
						Title = target?.Title,
						Unlocked = true
					} ) );
				}

				if ( normAction == "status" )
				{
					if ( string.IsNullOrWhiteSpace( name ) )
					{
						return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "achievement.missing_name", "Achievement name is required." ) );
					}

					var target = allAchievements.FirstOrDefault( a => string.Equals( a.Name, name.Trim(), StringComparison.OrdinalIgnoreCase ) );
					if ( target == null )
					{
						return McpJson.Write( ToolOutcome<object>.Failure( new { Name = name }, ToolStatus.Degraded, "achievement.not_found", $"Achievement '{name}' was not found in project definition." ) );
					}

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						target.Name,
						target.Title,
						target.Description,
						target.IsUnlocked,
						target.Score,
						target.CurrentValue,
						target.HasProgression,
						target.ProgressionFraction,
						target.GlobalFraction,
						target.UnlockTimestamp
					} ) );
				}

				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "achievement.unknown_action", $"Unknown achievement action '{action}'." ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "achievement.manage_failed", "Achievement operation failed.", ex ) );
			}
		}

		/// <summary>
		/// Inspects Steam friends, rich presence status, persona states, and party matchmaking
		/// via engine reflection for testing co-op lobbies, join requests, and invite flows.
		/// </summary>
		[McpTool( "pocketknife_social_manage" )]
		public static string SocialManage(
			[Description( "Action: 'list_friends', 'status'" )] string action = "list_friends",
			[Description( "Filter: 'all', 'online', 'playing_this_game'" )] string filter = "all",
			[Description( "Maximum friends to return (1-100, default 25)" )] int limit = 25 )
		{
			try
			{
				var normAction = action.ToLowerInvariant().Trim();
				var normFilter = filter.ToLowerInvariant().Trim();

				var friendsType = AppDomain.CurrentDomain.GetAssemblies()
					.SelectMany( a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } } )
					.FirstOrDefault( t => t.FullName == "Steamworks.SteamFriends" );

				var getFriendsMethod = friendsType?.GetMethod( "GetFriends", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static );
				var rawFriends = getFriendsMethod?.Invoke( null, null ) as IEnumerable;

				var parsedFriends = new List<object>();
				if ( rawFriends != null )
				{
					foreach ( var f in rawFriends )
					{
						if ( f == null ) continue;
						var fType = f.GetType();
						var name = fType.GetProperty( "Name" )?.GetValue( f )?.ToString() ?? "Unknown";
						var displayName = fType.GetProperty( "DisplayName" )?.GetValue( f )?.ToString() ?? name;
						var isOnline = (bool)(fType.GetProperty( "IsOnline" )?.GetValue( f ) ?? false);
						var isPlayingThisGame = (bool)(fType.GetProperty( "IsPlayingThisGame" )?.GetValue( f ) ?? false);
						var isPlaying = (bool)(fType.GetProperty( "IsPlaying" )?.GetValue( f ) ?? false);
						var isAway = (bool)(fType.GetProperty( "IsAway" )?.GetValue( f ) ?? false);
						var isBusy = (bool)(fType.GetProperty( "IsBusy" )?.GetValue( f ) ?? false);
						var state = fType.GetProperty( "State" )?.GetValue( f )?.ToString() ?? "Offline";
						var steamLevel = (int)(fType.GetProperty( "SteamLevel" )?.GetValue( f ) ?? 0);

						var rpMethod = fType.GetMethod( "GetRichPresence", new[] { typeof( string ) } );
						var richPresence = rpMethod?.Invoke( f, new object[] { "status" } )?.ToString();

						if ( normFilter == "online" && !isOnline ) continue;
						if ( normFilter == "playing_this_game" && !isPlayingThisGame ) continue;

						parsedFriends.Add( new
						{
							Name = name,
							DisplayName = displayName,
							IsOnline = isOnline,
							IsPlayingThisGame = isPlayingThisGame,
							IsPlaying = isPlaying,
							IsAway = isAway,
							IsBusy = isBusy,
							State = state,
							SteamLevel = steamLevel,
							RichPresence = richPresence
						} );
					}
				}

				var boundedLimit = Math.Clamp( limit, 1, 100 );
				var results = parsedFriends.Take( boundedLimit ).ToArray();

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					Action = normAction,
					Filter = normFilter,
					TotalAvailable = parsedFriends.Count,
					ReturnedCount = results.Length,
					Friends = results
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "social.manage_failed", "Social management operation failed.", ex ) );
			}
		}

		/// <summary>
		/// Diagnostics and spatial verification for active VoIP Voice components (Sandbox.Voice) in the scene,
		/// inspecting worldspace 3D spatialization, volume, lip-sync visemes, laughter score, and audio mixers.
		/// </summary>
		[McpTool( "pocketknife_voice_diagnostics" )]
		public static string VoiceDiagnostics(
			[Description( "Target GameObject name with Voice component (optional, scans all if omitted)" )] string? target = null )
		{
			try
			{
				var scene = ResolveActiveScene();
				if ( scene == null )
				{
					return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "voice.no_scene", "No active scene loaded." ) );
				}

				var voiceComponents = scene.GetAllComponents<Sandbox.Voice>().ToArray();
				if ( !string.IsNullOrWhiteSpace( target ) )
				{
					voiceComponents = voiceComponents.Where( v => string.Equals( v.GameObject.Name, target.Trim(), StringComparison.OrdinalIgnoreCase ) ).ToArray();
				}

				var results = voiceComponents.Select( v => new
				{
					Target = v.GameObject.Name,
					TargetId = v.GameObject.Id.ToString(),
					Position = v.WorldPosition,
					v.Volume,
					Mode = v.Mode.ToString(),
					v.WorldspacePlayback,
					v.Loopback,
					v.LipSync,
					v.Distance,
					v.Amplitude,
					v.LaughterScore,
					v.IsRecording,
					v.IsListening,
					VisemeCount = v.Visemes?.Count ?? 0
				} ).ToArray();

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					TotalVoiceComponents = results.Length,
					Voices = results
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "voice.diagnostics_failed", "Voice diagnostics failed.", ex ) );
			}
		}
	}
}