Editor/Tools/WorkflowTools.FacepunchCloudAndReplay.cs

Editor toolset for the s&box editor. It provides MCP (tool) endpoints to query Facepunch Cloud packages and leaderboards, manage networking lobbies, and record simple in-editor replay snapshots of physics-enabled entities.

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

namespace Editor.Mcp
{
	public static partial class SboxMcpAssistant
	{
		/// <summary>
		/// Queries Facepunch Cloud services directly from the editor: searches and fetches packages, models,
		/// gamemodes, sounds, and inspects game leaderboards.
		/// </summary>
		[McpTool( "pocketknife_facepunch_cloud_query" )]
		public static async Task<string> FacepunchCloudQuery(
			[Description( "Query type: 'package_search', 'package_fetch', 'leaderboard'" )] string queryType,
			[Description( "Search query or package ident (e.g. 'facepunch.terry', 'box_fight', 'cars')" )] string target,
			[Description( "Number of results to take for search queries (1-50)" )] int take = 10,
			[Description( "Optional stat name for leaderboards" )] string? statName = null )
		{
			try
			{
				var normType = queryType.ToLowerInvariant().Trim();

				if ( normType == "package_search" )
				{
					using var cts = new CancellationTokenSource( TimeSpan.FromSeconds( 10 ) );
					var result = await Package.FindAsync( target, Math.Clamp( take, 1, 50 ), 0, cts.Token );
					var packages = result?.Packages?.Select( p => new
					{
						p.Ident,
						p.Title,
						p.Summary,
						Type = p.TypeName,
						Org = p.Org?.Title,
						p.Thumb,
						p.Updated
					} ).ToArray() ?? Array.Empty<object>();

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Query = target,
						TotalFound = result?.TotalCount ?? 0,
						ReturnedCount = packages.Length,
						Packages = packages
					} ) );
				}

				if ( normType == "package_fetch" )
				{
					var pkg = await Package.FetchAsync( target, partial: false );
					if ( pkg == null )
					{
						return McpJson.Write( ToolOutcome<object>.Failure( new { Ident = target }, ToolStatus.Failed, "package.not_found", $"Package '{target}' was not found." ) );
					}

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						pkg.Ident,
						pkg.Title,
						pkg.Summary,
						pkg.Description,
						Type = pkg.TypeName,
						Org = pkg.Org?.Title,
						pkg.Thumb,
						pkg.Updated,
						Tags = pkg.Tags?.ToArray()
					} ) );
				}

				if ( normType == "leaderboard" )
				{
					long totalEntries = 0;
					string boardType = "standard";

					if ( !string.IsNullOrWhiteSpace( statName ) )
					{
						var board2 = Leaderboards.GetFromStat( target, statName );
						if ( board2 != null )
						{
							totalEntries = board2.TotalEntries;
							boardType = "stat";
						}
					}
					else
					{
						var board = Leaderboards.Get( target );
						if ( board != null )
						{
							totalEntries = board.TotalEntries;
							boardType = "named";
						}
					}

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Target = target,
						StatName = statName,
						BoardType = boardType,
						Status = "Leaderboard resolved",
						TotalEntries = totalEntries
					} ) );
				}


				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "cloud.type_invalid", $"Unsupported query type '{queryType}'. Use package_search, package_fetch, or leaderboard." ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "cloud.query_failed", "Facepunch cloud query failed.", ex ) );
			}
		}

		/// <summary>
		/// Creates, reconfigures, or inspects modern s&amp;box networking lobbies with privacy controls
		/// (Public, FriendsOnly, Invisible), max players, hidden mode, and host migration.
		/// </summary>
		[McpTool( "pocketknife_lobby_manage" )]
		public static string LobbyManage(
			[Description( "Action: 'create', 'status', 'disconnect', 'query'" )] string action = "status",
			[Description( "Lobby privacy: 'Public', 'FriendsOnly', 'Invisible'" )] string privacy = "Public",
			[Description( "Maximum players (1-64)" )] int maxPlayers = 8,
			[Description( "Lobby title/name" )] string? name = null,
			[Description( "Whether lobby is hidden from public matchmaking" )] bool hidden = false,
			[Description( "Whether lobby destroys when host leaves" )] bool destroyWhenHostLeaves = false )
		{
			try
			{
				var normAction = action.ToLowerInvariant().Trim();

				if ( normAction == "status" )
				{
					var connections = Connection.All?.Select( c => new
					{
						Id = c.Id.ToString(),
						c.DisplayName,
						c.SteamId
					} ).ToArray() ?? Array.Empty<object>();

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Networking.IsActive,
						Networking.IsHost,
						Networking.IsClient,
						Networking.IsConnecting,
						Networking.ServerName,
						Networking.MapName,
						Networking.MaxPlayers,
						ConnectedPlayerCount = connections.Length,
						Connections = connections
					} ) );
				}

				if ( normAction == "disconnect" )
				{
					Networking.Disconnect();
					return McpJson.Write( ToolOutcome<object>.Ready( new { Action = "disconnect", Disconnected = true } ) );
				}

				if ( normAction == "create" )
				{
					var parsedPrivacy = Enum.TryParse<LobbyPrivacy>( privacy, true, out var p ) ? p : LobbyPrivacy.Public;
					var config = new LobbyConfig
					{
						Privacy = parsedPrivacy,
						MaxPlayers = Math.Clamp( maxPlayers, 1, 64 ),
						Name = string.IsNullOrWhiteSpace( name ) ? "s&box Lobby" : name.Trim(),
						Hidden = hidden,
						DestroyWhenHostLeaves = destroyWhenHostLeaves
					};

					Networking.CreateLobby( config );

					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = "create",
						LobbyCreated = true,
						config.Name,
						Privacy = config.Privacy.ToString(),
						config.MaxPlayers,
						config.Hidden
					} ) );
				}

				return McpJson.Write( ToolOutcome<object>.Ready( new { Action = normAction, Status = "Ready" } ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "lobby.manage_failed", "Lobby management operation failed.", ex ) );
			}
		}

		private static readonly List<object> ActiveReplayFrames = new();
		private static bool IsRecordingReplay = false;

		/// <summary>
		/// Records or plays back session gameplay state snapshots for testing host/client interactions,
		/// inspecting physics trajectory deltas, and verifying player mechanics determinism.
		/// </summary>
		[McpTool( "pocketknife_replay_record" )]
		public static string ReplayRecord(
			[Description( "Action: 'start', 'snapshot_frame', 'stop', 'status'" )] string action = "status",
			[Description( "Tag for the recorded session" )] string tag = "session_0" )
		{
			try
			{
				var normAction = action.ToLowerInvariant().Trim();

				if ( normAction == "start" )
				{
					ActiveReplayFrames.Clear();
					IsRecordingReplay = true;
					return McpJson.Write( ToolOutcome<object>.Ready( new { Action = "start", Tag = tag, Recording = true, Timestamp = DateTime.UtcNow } ) );
				}

				if ( normAction == "snapshot_frame" && IsRecordingReplay )
				{
					var scene = ResolveActiveScene();
					if ( scene != null )
					{
						var frameData = scene.GetAllObjects( false )
							.Where( o => o.GetComponent<Rigidbody>() != null )
							.Select( o => new
							{
								o.Name,
								Position = o.WorldPosition,
								Angles = o.WorldRotation.Angles(),
								Velocity = o.GetComponent<Rigidbody>()!.Velocity
							} )
							.ToArray();

						ActiveReplayFrames.Add( new { FrameIndex = ActiveReplayFrames.Count, Time = Sandbox.Time.Now, Objects = frameData } );
					}
					return McpJson.Write( ToolOutcome<object>.Ready( new { Action = "snapshot_frame", FrameCount = ActiveReplayFrames.Count } ) );
				}

				if ( normAction == "stop" )
				{
					IsRecordingReplay = false;
					return McpJson.Write( ToolOutcome<object>.Ready( new
					{
						Action = "stop",
						Tag = tag,
						TotalFramesCaptured = ActiveReplayFrames.Count,
						Recording = false
					} ) );
				}

				return McpJson.Write( ToolOutcome<object>.Ready( new
				{
					Action = "status",
					IsRecording = IsRecordingReplay,
					CapturedFrames = ActiveReplayFrames.Count
				} ) );
			}
			catch ( Exception ex )
			{
				return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "replay.failed", "Replay operation failed.", ex ) );
			}
		}
	}
}