Editor/MyEditorMenu.cs
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
/// <summary>
/// Handler interface for bridge commands.
/// </summary>
public interface IBridgeHandler
{
Task<object> Execute( JsonElement parameters );
}
/// <summary>
/// Claude Bridge — file-based IPC server for MCP integration.
/// </summary>
public static class ClaudeBridge
{
private static readonly Dictionary<string, IBridgeHandler> _handlers = new();
private static bool _running;
private static string _ipcDir;
private static Timer _pollTimer;
// UTF-8 without BOM — Node.js JSON.parse rejects the BOM prefix
private static readonly Encoding _utf8NoBom = new UTF8Encoding( false );
static ClaudeBridge()
{
Log.Info( "[SboxBridge] Initializing..." );
RegisterHandlers();
StartBridge();
}
[Menu( "Editor", "Claude Bridge/Status", "smart_toy" )]
public static void ShowStatus()
{
var msg = _running
? $"Running\nIPC: {_ipcDir}\nHandlers: {_handlers.Count}"
: "Not running";
EditorUtility.DisplayDialog( "Claude Bridge", msg );
}
static void StartBridge()
{
if ( _running ) return;
try
{
_ipcDir = Path.Combine( Path.GetTempPath(), "sbox-bridge-ipc" );
Directory.CreateDirectory( _ipcDir );
var statusPath = Path.Combine( _ipcDir, "status.json" );
File.WriteAllText( statusPath, JsonSerializer.Serialize( new
{
running = true,
startedAt = DateTime.UtcNow.ToString( "o" ),
handlerCount = _handlers.Count
} ), _utf8NoBom );
_running = true;
// Use a Timer only to read request files from disk (IO is thread-safe)
// But queue the actual processing for the main thread
_pollTimer = new Timer( ReadRequestFiles, null, 500, 50 );
Log.Info( $"[SboxBridge] Bridge started — {_handlers.Count} handlers, IPC at {_ipcDir}" );
Log.Info( "[SboxBridge] s&box Claude Bridge by sboxskins.gg — https://sboxskins.gg" );
}
catch ( Exception ex )
{
Log.Warning( $"[SboxBridge] Failed to start: {ex.Message}" );
}
}
// Pending requests read from disk, to be processed on main thread
static readonly Queue<(string responseId, string json)> _pendingRequests = new();
static readonly object _queueLock = new();
static void RegisterHandlers()
{
// ── Batch 1: File / project basics ──────────────────────────────
Register( "get_project_info", new GetProjectInfoHandler() );
Register( "list_project_files", new ListProjectFilesHandler() );
Register( "read_file", new ReadFileHandler() );
Register( "write_file", new WriteFileHandler() );
Register( "create_script", new CreateScriptHandler() );
Register( "edit_script", new EditScriptHandler() );
Register( "delete_script", new DeleteScriptHandler() );
Register( "list_scenes", new ListScenesHandler() );
// ── Batch 2: Scene file operations ──────────────────────────────
Register( "load_scene", new LoadSceneHandler() );
Register( "save_scene", new SaveSceneHandler() );
Register( "create_scene", new CreateSceneHandler() );
// ── Batch 3: GameObject CRUD ─────────────────────────────────────
Register( "create_gameobject", new CreateGameObjectHandler() );
Register( "delete_gameobject", new DeleteGameObjectHandler() );
Register( "duplicate_gameobject",new DuplicateGameObjectHandler() );
Register( "rename_gameobject", new RenameGameObjectHandler() );
Register( "set_parent", new SetParentHandler() );
Register( "set_enabled", new SetEnabledHandler() );
Register( "set_transform", new SetTransformHandler() );
Register( "get_scene_hierarchy", new GetSceneHierarchyHandler() );
Register( "get_selected_objects",new GetSelectedObjectsHandler() );
Register( "select_object", new SelectObjectHandler() );
Register( "focus_object", new FocusObjectHandler() );
// ── Batch 4: Components ──────────────────────────────────────────
Register( "get_property", new GetPropertyHandler() );
Register( "get_all_properties", new GetAllPropertiesHandler() );
Register( "set_property", new SetPropertyHandler() );
Register( "set_prefab_ref", new SetPrefabRefHandler() );
Register( "list_available_components", new ListAvailableComponentsHandler() );
Register( "add_component_with_properties", new AddComponentWithPropertiesHandler() );
// ── Batch 5: Play mode ───────────────────────────────────────────
Register( "start_play", new StartPlayHandler() );
Register( "stop_play", new StopPlayHandler() );
// pause_play / resume_play — no API found, omitted
Register( "is_playing", new IsPlayingHandler() );
Register( "get_runtime_property",new GetRuntimePropertyHandler() );
Register( "set_runtime_property",new SetRuntimePropertyHandler() );
// ── Batch 6: Assets ──────────────────────────────────────────────
Register( "search_assets", new SearchAssetsHandler() );
Register( "get_asset_info", new GetAssetInfoHandler() );
Register( "assign_model", new AssignModelHandler() );
Register( "create_material", new CreateMaterialHandler() );
Register( "assign_material", new AssignMaterialHandler() );
Register( "set_material_property", new SetMaterialPropertyHandler() );
// ── Batch 7: Audio ───────────────────────────────────────────────
Register( "list_sounds", new ListSoundsHandler() );
Register( "create_sound_event", new CreateSoundEventHandler() );
Register( "assign_sound", new AssignSoundHandler() );
Register( "play_sound_preview", new PlaySoundPreviewHandler() );
// ── Batch 8: Prefabs ─────────────────────────────────────────────
Register( "create_prefab", new CreatePrefabHandler() );
Register( "instantiate_prefab", new InstantiatePrefabHandler() );
Register( "list_prefabs", new ListPrefabsHandler() );
Register( "get_prefab_info", new GetPrefabInfoHandler() );
// ── Batch 9: Physics ─────────────────────────────────────────────
Register( "add_physics", new AddPhysicsHandler() );
Register( "add_collider", new AddColliderHandler() );
Register( "add_joint", new AddJointHandler() );
Register( "raycast", new RaycastHandler() );
// ── Batch 10: Code templates ─────────────────────────────────────
Register( "create_player_controller", new CreatePlayerControllerHandler() );
Register( "create_npc_controller", new CreateNpcControllerHandler() );
Register( "create_game_manager", new CreateGameManagerHandler() );
Register( "create_trigger_zone", new CreateTriggerZoneHandler() );
// ── Batch 11: UI ─────────────────────────────────────────────────
Register( "create_razor_ui", new CreateRazorUIHandler() );
Register( "add_screen_panel", new AddScreenPanelHandler() );
Register( "add_world_panel", new AddWorldPanelHandler() );
// ── Batch 11b: Undo/Redo ─────────────────────────────────────────
Register( "undo", new UndoHandler() );
Register( "redo", new RedoHandler() );
// ── Batch 12: Networking ─────────────────────────────────────────
Register( "add_network_helper", new AddNetworkHelperHandler() );
Register( "configure_network", new ConfigureNetworkHandler() );
Register( "get_network_status", new GetNetworkStatusHandler() );
Register( "set_ownership", new SetOwnershipHandler() );
Register( "network_spawn", new NetworkSpawnHandler() );
Register( "add_sync_property", new AddSyncPropertyHandler() );
Register( "add_rpc_method", new AddRpcMethodHandler() );
Register( "create_networked_player", new CreateNetworkedPlayerHandler() );
Register( "create_lobby_manager", new CreateLobbyManagerHandler() );
Register( "create_network_events", new CreateNetworkEventsHandler() );
// ── Batch 13: Publishing / config ────────────────────────────────
Register( "get_project_config", new GetProjectConfigHandler() );
Register( "set_project_config", new SetProjectConfigHandler() );
Register( "validate_project", new ValidateProjectHandler() );
Register( "set_project_thumbnail",new SetProjectThumbnailHandler() );
Register( "get_package_details", new GetPackageDetailsHandler() );
Register( "install_asset", new InstallAssetHandler() );
Register( "list_asset_library", new ListAssetLibraryHandler() );
// ── Batch 14: Console / diagnostics ─────────────────────────────
// get_console_output / get_compile_errors / clear_console — LogCapture not available, omitted
Register( "take_screenshot", new TakeScreenshotHandler() );
Register( "trigger_hotload", new TriggerHotloadHandler() );
// ── Batch 15: Terrain / Map building ────────────────────────────
Register( "build_terrain_mesh", new BuildTerrainMeshHandler() );
Register( "invoke_button", new InvokeButtonHandler() );
Register( "list_component_buttons", new ListComponentButtonsHandler() );
Register( "raycast_terrain", new RaycastTerrainHandler() );
Register( "add_terrain_hill", new AddTerrainHillHandler() );
Register( "add_terrain_clearing", new AddTerrainClearingHandler() );
Register( "add_terrain_trail", new AddTerrainTrailHandler() );
Register( "clear_terrain_features", new ClearTerrainFeaturesHandler() );
Register( "add_cave_waypoint", new AddCaveWaypointHandler() );
Register( "clear_cave_path", new ClearCavePathHandler() );
Register( "add_forest_poi", new AddForestPOIHandler() );
Register( "add_forest_trail", new AddForestTrailHandler() );
Register( "set_forest_seed", new SetForestSeedHandler() );
Register( "clear_forest_pois", new ClearForestPOIsHandler() );
Register( "sculpt_terrain", new SculptTerrainHandler() );
Register( "paint_forest_density", new PaintForestDensityHandler() );
Register( "place_along_path", new PlaceAlongPathHandler() );
// ── Batch 16: Coding / type discovery ───────────────────────────
Register( "describe_type", new DescribeTypeHandler() );
Register( "search_types", new SearchTypesHandler() );
Register( "get_method_signature", new GetMethodSignatureHandler() );
Register( "find_in_project", new FindInProjectHandler() );
Log.Info( $"[SboxBridge] Registered {_handlers.Count} handlers" );
}
public static int HandlerCount => _handlers.Count;
static void Register( string name, IBridgeHandler handler )
{
_handlers[name] = handler;
}
/// <summary>
/// Runs on a timer thread — only reads files from disk and queues them.
/// </summary>
static void ReadRequestFiles( object state )
{
if ( !_running || _ipcDir == null ) return;
try
{
var files = Directory.GetFiles( _ipcDir, "req_*.json" );
foreach ( var reqFile in files )
{
try
{
var json = File.ReadAllText( reqFile, Encoding.UTF8 );
File.Delete( reqFile );
var fileName = Path.GetFileNameWithoutExtension( reqFile );
var responseId = fileName.Substring( 4 );
lock ( _queueLock )
{
_pendingRequests.Enqueue( (responseId, json) );
}
}
catch ( IOException ) { }
catch ( Exception ex )
{
Log.Warning( $"[SboxBridge] Read error: {ex.Message}" );
}
}
}
catch { }
}
/// <summary>
/// Called on the main thread by BridgePoller widget.
/// Processes queued requests where scene APIs are safe to call.
/// </summary>
public static void ProcessPendingOnMainThread()
{
while ( true )
{
(string responseId, string json) item;
lock ( _queueLock )
{
if ( _pendingRequests.Count == 0 ) break;
item = _pendingRequests.Dequeue();
}
string response;
try { response = ProcessRequest( item.json ).GetAwaiter().GetResult(); }
catch ( Exception ex ) { response = MakeError( null, $"Processing error: {ex.Message}" ); }
try
{
var responsePath = Path.Combine( _ipcDir, $"res_{item.responseId}.json" );
File.WriteAllText( responsePath, response, _utf8NoBom );
}
catch ( Exception ex )
{
Log.Warning( $"[SboxBridge] Write error: {ex.Message}" );
}
}
}
static async Task<string> ProcessRequest( string json )
{
using var doc = JsonDocument.Parse( json );
var root = doc.RootElement;
var id = root.TryGetProperty( "id", out var idProp ) ? idProp.GetString() : null;
var command = root.TryGetProperty( "command", out var cmdProp ) ? cmdProp.GetString() : null;
if ( string.IsNullOrEmpty( id ) )
return MakeError( null, "Missing 'id'" );
if ( string.IsNullOrEmpty( command ) )
return MakeError( id, "Missing 'command'" );
// Built-in status command
if ( command == "get_bridge_status" )
{
return JsonSerializer.Serialize( new
{
id, success = true,
data = new
{
connected = true,
running = _running,
handlerCount = _handlers.Count,
registeredCommands = _handlers.Keys.ToArray()
}
} );
}
// Set prefab reference (inline — handles GameObject properties that set_property can't)
if ( command == "set_prefab_ref" )
{
try
{
var sceneRef = SceneEditorSession.Active?.Scene;
if ( sceneRef == null )
return JsonSerializer.Serialize( new { id, success = false, error = "No active scene" } );
var paramsEl = root.GetProperty( "params" );
var targetIdStr = paramsEl.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( targetIdStr, out var targetGuid ) )
return JsonSerializer.Serialize( new { id, success = false, error = "Invalid target GUID" } );
var targetGo = sceneRef.Directory.FindByGuid( targetGuid );
if ( targetGo == null )
return JsonSerializer.Serialize( new { id, success = false, error = "Target GameObject not found" } );
var componentType = paramsEl.GetProperty( "component" ).GetString();
var propertyName = paramsEl.GetProperty( "property" ).GetString();
var prefabPath = paramsEl.GetProperty( "prefabPath" ).GetString();
var comp = targetGo.Components.GetAll()
.FirstOrDefault( c => c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) );
if ( comp == null )
return JsonSerializer.Serialize( new { id, success = false, error = $"Component not found: {componentType}" } );
var prefabFile = ResourceLibrary.Get<PrefabFile>( prefabPath );
if ( prefabFile == null )
return JsonSerializer.Serialize( new { id, success = false, error = $"Prefab not found: {prefabPath}" } );
GameObject prefabGo = null;
try { prefabGo = SceneUtility.GetPrefabScene( prefabFile ); }
catch ( Exception ex ) { return JsonSerializer.Serialize( new { id, success = false, error = $"GetPrefabScene failed: {ex.Message}" } ); }
if ( prefabGo == null )
return JsonSerializer.Serialize( new { id, success = false, error = "Prefab scene is null" } );
var tDesc = Game.TypeLibrary.GetType( comp.GetType().Name );
var prop = tDesc?.Properties.FirstOrDefault( pp => pp.Name == propertyName );
if ( prop == null )
return JsonSerializer.Serialize( new { id, success = false, error = $"Property not found: {propertyName}" } );
prop.SetValue( comp, prefabGo );
return JsonSerializer.Serialize( new { id, success = true, data = new { wired = propertyName, prefab = prefabPath } } );
}
catch ( Exception ex )
{
return MakeError( id, $"set_prefab_ref error: {ex.Message}" );
}
}
if ( _handlers.TryGetValue( command, out var handler ) )
{
try
{
var paramsElement = root.TryGetProperty( "params", out var p ) ? p : default;
var result = await handler.Execute( paramsElement );
return JsonSerializer.Serialize( new { id, success = true, data = result } );
}
catch ( Exception ex )
{
return MakeError( id, $"Handler error: {ex.Message}" );
}
}
return MakeError( id, $"Unknown command: {command}" );
}
static string MakeError( string id, string message )
{
return JsonSerializer.Serialize( new { id, success = false, error = message } );
}
// ── Shared helpers ────────────────────────────────────────────────────
internal static Vector3 ParseVector3( JsonElement e )
{
float x = e.TryGetProperty( "x", out var ex ) ? ex.GetSingle() : 0f;
float y = e.TryGetProperty( "y", out var ey ) ? ey.GetSingle() : 0f;
float z = e.TryGetProperty( "z", out var ez ) ? ez.GetSingle() : 0f;
return new Vector3( x, y, z );
}
internal static Rotation ParseRotation( JsonElement e )
{
float pitch = e.TryGetProperty( "pitch", out var ep ) ? ep.GetSingle() : 0f;
float yaw = e.TryGetProperty( "yaw", out var ey ) ? ey.GetSingle() : 0f;
float roll = e.TryGetProperty( "roll", out var er ) ? er.GetSingle() : 0f;
return Rotation.From( pitch, yaw, roll );
}
internal static object SerializeGo( GameObject go )
{
return new
{
id = go.Id.ToString(),
name = go.Name,
enabled = go.Enabled,
parent = go.Parent?.Id.ToString(),
position = new { go.WorldPosition.x, go.WorldPosition.y, go.WorldPosition.z },
rotation = new { pitch = go.WorldRotation.Pitch(), yaw = go.WorldRotation.Yaw(), roll = go.WorldRotation.Roll() },
scale = new { go.WorldScale.x, go.WorldScale.y, go.WorldScale.z },
components = go.Components.GetAll().Select( c => c.GetType().Name ).ToArray(),
childCount = go.Children.Count
};
}
internal static object SerializeGoTree( GameObject go )
{
return new
{
id = go.Id.ToString(),
name = go.Name,
enabled = go.Enabled,
components = go.Components.GetAll().Select( c => c.GetType().Name ).ToArray(),
children = go.Children.Select( c => SerializeGoTree( c ) ).ToArray()
};
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 1 — File / project basics (unchanged)
// ═══════════════════════════════════════════════════════════════════
public class GetProjectInfoHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var project = Project.Current;
return Task.FromResult<object>( new
{
name = project.Config.Title,
org = project.Config.Org,
ident = project.Config.Ident,
type = project.Config.Type,
path = project.GetRootPath(),
assetsPath = project.GetAssetsPath()
} );
}
}
public class ListProjectFilesHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var dir = p.TryGetProperty( "path", out var d ) ? d.GetString() : "";
var extension = p.TryGetProperty( "extension", out var e ) ? e.GetString() : null;
var recursive = !p.TryGetProperty( "recursive", out var rec ) || rec.GetBoolean();
var searchDir = string.IsNullOrEmpty( dir )
? rootPath
: Path.Combine( rootPath, dir );
if ( !Directory.Exists( searchDir ) )
return Task.FromResult<object>( new { error = $"Directory not found: {dir}", files = Array.Empty<string>() } );
var files = Directory.GetFiles( searchDir, "*.*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly )
.Select( f => Path.GetRelativePath( rootPath, f ).Replace( '\\', '/' ) )
.Where( f => extension == null || f.EndsWith( extension, StringComparison.OrdinalIgnoreCase ) )
.Take( 500 )
.ToArray();
return Task.FromResult<object>( new { path = dir, count = files.Length, files } );
}
}
public class ReadFileHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var filePath = p.GetProperty( "path" ).GetString();
var fullPath = Path.GetFullPath( Path.Combine( rootPath, filePath ) );
if ( !fullPath.StartsWith( rootPath, StringComparison.OrdinalIgnoreCase ) )
return Task.FromResult<object>( new { error = "Path traversal denied" } );
if ( !File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File not found: {filePath}" } );
var content = File.ReadAllText( fullPath );
return Task.FromResult<object>( new { path = filePath, content, length = content.Length } );
}
}
public class WriteFileHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var filePath = p.GetProperty( "path" ).GetString();
var content = p.GetProperty( "content" ).GetString();
var fullPath = Path.GetFullPath( Path.Combine( rootPath, filePath ) );
if ( !fullPath.StartsWith( rootPath, StringComparison.OrdinalIgnoreCase ) )
return Task.FromResult<object>( new { error = "Path traversal denied" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
File.WriteAllText( fullPath, content );
return Task.FromResult<object>( new { path = filePath, written = true, length = content.Length } );
}
}
public class CreateScriptHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.GetProperty( "name" ).GetString();
var template = p.TryGetProperty( "template", out var t ) ? t.GetString() : "component";
var directory = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Code";
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
var fullPath = Path.Combine( rootPath, directory, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var className = Path.GetFileNameWithoutExtension( fileName );
var code = template switch
{
"component" => $"using Sandbox;\n\npublic sealed class {className} : Component\n{{\n\tprotected override void OnUpdate()\n\t{{\n\t}}\n}}\n",
"raw" => p.TryGetProperty( "content", out var c ) ? c.GetString() : $"// {className}\n",
_ => $"using Sandbox;\n\npublic sealed class {className} : Component\n{{\n}}\n",
};
File.WriteAllText( fullPath, code );
return Task.FromResult<object>( new { path = $"{directory}/{fileName}", created = true, className } );
}
}
public class EditScriptHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var filePath = p.GetProperty( "path" ).GetString();
var fullPath = Path.GetFullPath( Path.Combine( rootPath, filePath ) );
if ( !fullPath.StartsWith( rootPath, StringComparison.OrdinalIgnoreCase ) )
return Task.FromResult<object>( new { error = "Path traversal denied" } );
if ( !File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File not found: {filePath}" } );
var content = File.ReadAllText( fullPath );
if ( p.TryGetProperty( "find", out var find ) && p.TryGetProperty( "replace", out var replace ) )
{
var findStr = find.GetString();
var replaceStr = replace.GetString();
if ( !content.Contains( findStr ) )
return Task.FromResult<object>( new { error = $"Text not found: {findStr}" } );
content = content.Replace( findStr, replaceStr );
File.WriteAllText( fullPath, content );
return Task.FromResult<object>( new { path = filePath, edited = true, operation = "find_replace" } );
}
if ( p.TryGetProperty( "content", out var newContent ) )
{
File.WriteAllText( fullPath, newContent.GetString() );
return Task.FromResult<object>( new { path = filePath, edited = true, operation = "overwrite" } );
}
return Task.FromResult<object>( new { error = "Provide 'find'/'replace' or 'content'" } );
}
}
public class DeleteScriptHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var filePath = p.GetProperty( "path" ).GetString();
var fullPath = Path.GetFullPath( Path.Combine( rootPath, filePath ) );
if ( !fullPath.StartsWith( rootPath, StringComparison.OrdinalIgnoreCase ) )
return Task.FromResult<object>( new { error = "Path traversal denied" } );
if ( !File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File not found: {filePath}" } );
File.Delete( fullPath );
return Task.FromResult<object>( new { path = filePath, deleted = true } );
}
}
public class ListScenesHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var scenes = Directory.GetFiles( rootPath, "*.scene", SearchOption.AllDirectories )
.Select( f => Path.GetRelativePath( rootPath, f ).Replace( '\\', '/' ) )
.ToArray();
return Task.FromResult<object>( new { count = scenes.Length, scenes } );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 2 — Scene file operations
// ═══════════════════════════════════════════════════════════════════
public class LoadSceneHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scenePath = p.GetProperty( "path" ).GetString();
var rootPath = Project.Current.GetRootPath();
// Try as relative path first, then absolute
var fullPath = Path.IsPathRooted( scenePath )
? scenePath
: Path.GetFullPath( Path.Combine( rootPath, scenePath ) );
if ( !File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"Scene file not found: {scenePath}" } );
try
{
// SceneFile is the resource type for .scene files
var sceneFile = ResourceLibrary.Get<SceneFile>( scenePath );
if ( sceneFile != null )
{
EditorScene.OpenScene( sceneFile );
return Task.FromResult<object>( new { loaded = true, path = scenePath } );
}
return Task.FromResult<object>( new { error = "Could not load scene resource. Try using a path relative to the assets folder." } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to load scene: {ex.Message}" } );
}
}
}
public class SaveSceneHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
EditorScene.SaveSession();
return Task.FromResult<object>( new { saved = true } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to save scene: {ex.Message}" } );
}
}
}
public class CreateSceneHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var name = p.GetProperty( "name" ).GetString();
var rootPath = Project.Current.GetRootPath();
var subdir = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Scenes";
var fileName = name.EndsWith( ".scene" ) ? name : $"{name}.scene";
var fullPath = Path.Combine( rootPath, subdir, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"Scene already exists: {subdir}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
// Minimal valid s&box scene JSON
var sceneJson = JsonSerializer.Serialize( new
{
__version = 0,
__referencedFiles = Array.Empty<string>(),
GameObjects = Array.Empty<object>()
}, new JsonSerializerOptions { WriteIndented = true } );
File.WriteAllText( fullPath, sceneJson );
var relativePath = Path.GetRelativePath( rootPath, fullPath ).Replace( '\\', '/' );
return Task.FromResult<object>( new { created = true, path = relativePath } );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 3 — GameObject CRUD
// ═══════════════════════════════════════════════════════════════════
public class CreateGameObjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "GameObject";
var go = scene.CreateObject( true );
go.Name = name;
if ( p.TryGetProperty( "position", out var pos ) )
go.WorldPosition = ClaudeBridge.ParseVector3( pos );
if ( p.TryGetProperty( "rotation", out var rot ) )
go.WorldRotation = ClaudeBridge.ParseRotation( rot );
if ( p.TryGetProperty( "scale", out var scl ) )
go.WorldScale = ClaudeBridge.ParseVector3( scl );
if ( p.TryGetProperty( "parentId", out var pid ) && Guid.TryParse( pid.GetString(), out var parentGuid ) )
{
var parent = scene.Directory.FindByGuid( parentGuid );
if ( parent != null )
go.SetParent( parent, keepWorldPosition: true );
}
if ( p.TryGetProperty( "tags", out var tags ) && tags.ValueKind == JsonValueKind.Array )
{
foreach ( var tag in tags.EnumerateArray() )
go.Tags.Add( tag.GetString() );
}
return Task.FromResult<object>( new { created = true, gameObject = ClaudeBridge.SerializeGo( go ) } );
}
}
public class DeleteGameObjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var name = go.Name;
go.Destroy();
return Task.FromResult<object>( new { deleted = true, id, name } );
}
}
public class DuplicateGameObjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var clone = go.Clone();
if ( p.TryGetProperty( "offset", out var off ) )
clone.WorldPosition = go.WorldPosition + ClaudeBridge.ParseVector3( off );
if ( p.TryGetProperty( "name", out var nm ) )
clone.Name = nm.GetString();
return Task.FromResult<object>( new { duplicated = true, original = id, gameObject = ClaudeBridge.SerializeGo( clone ) } );
}
}
public class RenameGameObjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var oldName = go.Name;
go.Name = p.GetProperty( "name" ).GetString();
return Task.FromResult<object>( new { renamed = true, id, oldName, newName = go.Name } );
}
}
public class SetParentHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid child GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var keepWorld = !p.TryGetProperty( "keepWorldPosition", out var kw ) || kw.GetBoolean();
// parentId == null → detach to root
if ( p.TryGetProperty( "parentId", out var pid ) && pid.ValueKind != JsonValueKind.Null )
{
if ( !Guid.TryParse( pid.GetString(), out var parentGuid ) )
return Task.FromResult<object>( new { error = "Invalid parent GUID" } );
var parent = scene.Directory.FindByGuid( parentGuid );
if ( parent == null )
return Task.FromResult<object>( new { error = $"Parent not found: {pid.GetString()}" } );
go.SetParent( parent, keepWorld );
return Task.FromResult<object>( new { parented = true, id, parentId = pid.GetString() } );
}
go.SetParent( null, keepWorld );
return Task.FromResult<object>( new { parented = true, id, parentId = (string)null } );
}
}
public class SetEnabledHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var enabled = p.GetProperty( "enabled" ).GetBoolean();
go.Enabled = enabled;
return Task.FromResult<object>( new { id, enabled } );
}
}
public class SetTransformHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var local = p.TryGetProperty( "local", out var lc ) && lc.GetBoolean();
if ( p.TryGetProperty( "position", out var pos ) )
{
if ( local ) go.LocalPosition = ClaudeBridge.ParseVector3( pos );
else go.WorldPosition = ClaudeBridge.ParseVector3( pos );
}
if ( p.TryGetProperty( "rotation", out var rot ) )
{
if ( local ) go.LocalRotation = ClaudeBridge.ParseRotation( rot );
else go.WorldRotation = ClaudeBridge.ParseRotation( rot );
}
if ( p.TryGetProperty( "scale", out var scl ) )
{
if ( local ) go.LocalScale = ClaudeBridge.ParseVector3( scl );
else go.WorldScale = ClaudeBridge.ParseVector3( scl );
}
return Task.FromResult<object>( new { transformed = true, gameObject = ClaudeBridge.SerializeGo( go ) } );
}
}
public class GetSceneHierarchyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var roots = scene.Children
.Select( go => ClaudeBridge.SerializeGoTree( go ) )
.ToArray();
return Task.FromResult<object>( new
{
sceneName = scene.Name,
objectCount = scene.GetAllObjects( true ).Count(),
hierarchy = roots
} );
}
}
public class GetSelectedObjectsHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var selected = SceneEditorSession.Active.Selection
.OfType<GameObject>()
.Select( go => ClaudeBridge.SerializeGo( go ) )
.ToArray();
return Task.FromResult<object>( new { count = selected.Length, selected } );
}
}
public class SelectObjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var add = p.TryGetProperty( "addToSelection", out var at ) && at.GetBoolean();
if ( add )
SceneEditorSession.Active.Selection.Add( go );
else
SceneEditorSession.Active.Selection.Set( go );
return Task.FromResult<object>( new { selected = true, id } );
}
}
public class FocusObjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
// No dedicated focus API — select the object so the editor highlights it
SceneEditorSession.Active.Selection.Set( go );
return Task.FromResult<object>( new { focused = true, id, note = "Object selected in editor (no separate focus API)" } );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 4 — Components
// ═══════════════════════════════════════════════════════════════════
public class GetPropertyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var componentType = p.TryGetProperty( "component", out var ct ) ? ct.GetString() : null;
var propertyName = p.GetProperty( "property" ).GetString();
var component = FindComponent( go, componentType );
if ( component == null )
return Task.FromResult<object>( new { error = $"Component not found: {componentType}" } );
try
{
var typeDesc = Game.TypeLibrary.GetType( component.GetType().Name );
var propDesc = typeDesc?.Properties.FirstOrDefault( pp => pp.Name == propertyName );
if ( propDesc == null )
return Task.FromResult<object>( new { error = $"Property not found: {propertyName}" } );
var value = propDesc.GetValue( component );
return Task.FromResult<object>( new { id, component = component.GetType().Name, property = propertyName, value = value?.ToString() } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to get property: {ex.Message}" } );
}
}
static Component FindComponent( GameObject go, string typeName )
{
if ( string.IsNullOrEmpty( typeName ) )
return go.Components.GetAll().FirstOrDefault();
return go.Components.GetAll()
.FirstOrDefault( c => c.GetType().Name.Equals( typeName, StringComparison.OrdinalIgnoreCase ) );
}
}
public class GetAllPropertiesHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var result = new List<object>();
foreach ( var component in go.Components.GetAll() )
{
var typeName = component.GetType().Name;
var typeDesc = Game.TypeLibrary.GetType( typeName );
var props = new List<object>();
if ( typeDesc != null )
{
foreach ( var propDesc in typeDesc.Properties )
{
try
{
var value = propDesc.GetValue( component );
props.Add( new { name = propDesc.Name, type = propDesc.PropertyType?.Name, value = value?.ToString() } );
}
catch { props.Add( new { name = propDesc.Name, type = propDesc.PropertyType?.Name, value = "<error>" } ); }
}
}
result.Add( new { component = typeName, properties = props } );
}
return Task.FromResult<object>( new { id, components = result } );
}
}
/// <summary>
/// Sets a GameObject-typed property on a component to a loaded prefab.
/// Use this when you need to assign a prefab reference that set_property can't handle.
/// </summary>
public class SetPrefabRefHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var componentType = p.GetProperty( "component" ).GetString();
var propertyName = p.GetProperty( "property" ).GetString();
var prefabPath = p.GetProperty( "prefabPath" ).GetString();
var component = go.Components.GetAll()
.FirstOrDefault( c => c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) );
if ( component == null )
return Task.FromResult<object>( new { error = $"Component not found: {componentType}" } );
// Load the prefab
var prefabFile = ResourceLibrary.Get<PrefabFile>( prefabPath );
if ( prefabFile == null )
return Task.FromResult<object>( new { error = $"Prefab not found: {prefabPath}" } );
// Get the GameObject from the prefab scene
GameObject prefabGo = null;
try
{
prefabGo = SceneUtility.GetPrefabScene( prefabFile );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to get prefab scene: {ex.Message}" } );
}
if ( prefabGo == null )
return Task.FromResult<object>( new { error = "Prefab scene GameObject is null" } );
try
{
var typeDesc = Game.TypeLibrary.GetType( component.GetType().Name );
var propDesc = typeDesc?.Properties.FirstOrDefault( pp => pp.Name == propertyName );
if ( propDesc == null )
return Task.FromResult<object>( new { error = $"Property not found: {propertyName}" } );
propDesc.SetValue( component, prefabGo );
return Task.FromResult<object>( new { set = true, id, component = componentType, property = propertyName, prefabPath } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to set prefab ref: {ex.Message}" } );
}
}
}
public class SetPropertyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var componentType = p.TryGetProperty( "component", out var ct ) ? ct.GetString() : null;
var propertyName = p.GetProperty( "property" ).GetString();
var valueStr = p.GetProperty( "value" ).GetString();
var component = go.Components.GetAll()
.FirstOrDefault( c => string.IsNullOrEmpty( componentType ) ||
c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) );
if ( component == null )
return Task.FromResult<object>( new { error = $"Component not found: {componentType}" } );
try
{
var typeDesc = Game.TypeLibrary.GetType( component.GetType().Name );
var propDesc = typeDesc?.Properties.FirstOrDefault( pp => pp.Name == propertyName );
if ( propDesc == null )
return Task.FromResult<object>( new { error = $"Property not found: {propertyName}" } );
// Attempt type-safe conversion
var propType = propDesc.PropertyType;
object typedValue = propType?.Name switch
{
"Single" or "float" => float.Parse( valueStr ),
"Double" or "double" => double.Parse( valueStr ),
"Int32" or "int" => int.Parse( valueStr ),
"Boolean" or "bool" => bool.Parse( valueStr ),
"String" or "string" => valueStr,
_ => valueStr
};
propDesc.SetValue( component, typedValue );
return Task.FromResult<object>( new { set = true, id, component = component.GetType().Name, property = propertyName, value = valueStr } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to set property: {ex.Message}" } );
}
}
}
public class ListAvailableComponentsHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var filter = p.TryGetProperty( "filter", out var f ) ? f.GetString() : null;
var types = Game.TypeLibrary.GetTypes<Component>()
.Where( t => !t.IsAbstract )
.Where( t => filter == null || t.Name.Contains( filter, StringComparison.OrdinalIgnoreCase ) )
.Select( t => new { name = t.Name, title = t.Title, description = t.Description, fullName = t.FullName } )
.OrderBy( t => t.name )
.ToArray();
return Task.FromResult<object>( new { count = types.Length, components = types } );
}
}
public class AddComponentWithPropertiesHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var typeName = p.GetProperty( "component" ).GetString();
var typeDesc = Game.TypeLibrary.GetType( typeName );
if ( typeDesc == null )
return Task.FromResult<object>( new { error = $"Component type not found: {typeName}" } );
try
{
var component = go.Components.Create( typeDesc );
if ( component == null )
return Task.FromResult<object>( new { error = "Failed to create component instance" } );
// Apply optional property overrides
if ( p.TryGetProperty( "properties", out var props ) && props.ValueKind == JsonValueKind.Object )
{
foreach ( var prop in props.EnumerateObject() )
{
try
{
var pd = typeDesc.Properties.FirstOrDefault( pp => pp.Name == prop.Name );
if ( pd != null )
{
var propType = pd.PropertyType;
object typedValue = propType?.Name switch
{
"Single" or "float" => float.Parse( prop.Value.GetString() ),
"Double" or "double" => double.Parse( prop.Value.GetString() ),
"Int32" or "int" => int.Parse( prop.Value.GetString() ),
"Boolean" or "bool" => prop.Value.ValueKind == JsonValueKind.True,
_ => prop.Value.GetString()
};
pd.SetValue( component, typedValue );
}
}
catch { /* best-effort property set */ }
}
}
return Task.FromResult<object>( new { added = true, id, component = typeName } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to add component: {ex.Message}" } );
}
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 5 — Play mode
// ═══════════════════════════════════════════════════════════════════
/// <summary>
/// Tracks editor play-mode state since Game.IsPlaying isn't reliable in editor context.
/// </summary>
public static class PlayState
{
public static bool IsPlaying;
}
public class StartPlayHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var session = SceneEditorSession.Active;
if ( session == null )
return Task.FromResult<object>( new { error = "No active scene session" } );
// Try the safe path first — matches what the editor Play button does.
// This serializes the scene to catch any invalid state before actually playing.
try
{
EditorScene.Play( session );
PlayState.IsPlaying = true;
return Task.FromResult<object>( new { started = true, method = "EditorScene.Play" } );
}
catch ( Exception editorEx )
{
// Fall back to direct SetPlaying. This skips scene serialization, which
// is a workaround but can leave the editor in a half-play state if the
// scene has invalid components. Only use if EditorScene.Play fails.
try
{
session.SetPlaying( session.Scene );
PlayState.IsPlaying = true;
return Task.FromResult<object>( new
{
started = true,
method = "SetPlaying (fallback)",
editorErrorSkipped = editorEx.Message
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new
{
error = $"Failed both paths. Editor: {editorEx.Message} | Direct: {ex.Message}"
} );
}
}
}
}
public class StopPlayHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
SceneEditorSession.Active?.StopPlaying();
PlayState.IsPlaying = false;
return Task.FromResult<object>( new { stopped = true } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to stop play: {ex.Message}" } );
}
}
}
public class IsPlayingHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
// Check multiple signals: our tracked flag, Game.IsPlaying, and whether
// the active scene is a game scene vs. editor scene.
var tracked = PlayState.IsPlaying;
var gameFlag = Game.IsPlaying;
// Editor scene and game scene diverge during play mode
bool sessionPlaying = false;
try
{
var session = SceneEditorSession.Active;
if ( session != null && Game.ActiveScene != null )
{
sessionPlaying = Game.ActiveScene != session.Scene;
}
}
catch { }
var isPlaying = tracked || gameFlag || sessionPlaying;
return Task.FromResult<object>( new
{
isPlaying,
isPaused = Game.IsPaused,
gameFlag,
tracked,
sessionPlaying
} );
}
}
public class GetRuntimePropertyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
if ( !Game.IsPlaying )
return Task.FromResult<object>( new { error = "Game is not currently playing" } );
// Reuse GetPropertyHandler logic
return new GetPropertyHandler().Execute( p );
}
}
public class SetRuntimePropertyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
if ( !Game.IsPlaying )
return Task.FromResult<object>( new { error = "Game is not currently playing" } );
return new SetPropertyHandler().Execute( p );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 6 — Assets
// ═══════════════════════════════════════════════════════════════════
public class SearchAssetsHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var query = p.TryGetProperty( "query", out var q ) ? q.GetString() : null;
var extension = p.TryGetProperty( "extension", out var e ) ? e.GetString() : null;
var files = Directory.GetFiles( rootPath, "*.*", SearchOption.AllDirectories )
.Select( f => Path.GetRelativePath( rootPath, f ).Replace( '\\', '/' ) )
.Where( f => extension == null || f.EndsWith( extension, StringComparison.OrdinalIgnoreCase ) )
.Where( f => query == null || f.Contains( query, StringComparison.OrdinalIgnoreCase ) )
.Take( 200 )
.ToArray();
return Task.FromResult<object>( new { count = files.Length, assets = files } );
}
}
public class GetAssetInfoHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var filePath = p.GetProperty( "path" ).GetString();
var fullPath = Path.GetFullPath( Path.Combine( rootPath, filePath ) );
if ( !File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"Asset not found: {filePath}" } );
var info = new FileInfo( fullPath );
return Task.FromResult<object>( new
{
path = filePath,
name = info.Name,
extension = info.Extension,
size = info.Length,
modified = info.LastWriteTimeUtc.ToString( "o" )
} );
}
}
public class AssignModelHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var modelPath = p.GetProperty( "model" ).GetString();
var model = Model.Load( modelPath );
if ( model == null )
return Task.FromResult<object>( new { error = $"Model not found: {modelPath}" } );
var renderer = go.GetOrAddComponent<ModelRenderer>();
renderer.Model = model;
return Task.FromResult<object>( new { assigned = true, id, model = modelPath } );
}
}
public class CreateMaterialHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.GetProperty( "name" ).GetString();
var subdir = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Materials";
var fileName = name.EndsWith( ".vmat" ) ? name : $"{name}.vmat";
var fullPath = Path.Combine( rootPath, subdir, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"Material already exists: {subdir}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var shader = p.TryGetProperty( "shader", out var sh ) ? sh.GetString() : "shaders/simple.shader";
var vmat = $"// THIS FILE IS AUTO-GENERATED\n\"Layer0\"\n{{\n\tshader \"{shader}\"\n\n\tF_SELF_ILLUM 0\n\n\tTextureColor \"materials/default/default.tga\"\n}}\n";
File.WriteAllText( fullPath, vmat );
var relativePath = Path.GetRelativePath( rootPath, fullPath ).Replace( '\\', '/' );
return Task.FromResult<object>( new { created = true, path = relativePath } );
}
}
public class AssignMaterialHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var materialPath = p.GetProperty( "material" ).GetString();
var material = Material.Load( materialPath );
if ( material == null )
return Task.FromResult<object>( new { error = $"Material not found: {materialPath}" } );
var renderer = go.GetComponent<ModelRenderer>();
if ( renderer == null )
return Task.FromResult<object>( new { error = "No ModelRenderer on GameObject" } );
renderer.MaterialOverride = material;
return Task.FromResult<object>( new { assigned = true, id, material = materialPath } );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 7 — Audio
// ═══════════════════════════════════════════════════════════════════
public class ListSoundsHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var sounds = Directory.GetFiles( rootPath, "*.sound", SearchOption.AllDirectories )
.Select( f => Path.GetRelativePath( rootPath, f ).Replace( '\\', '/' ) )
.ToArray();
return Task.FromResult<object>( new { count = sounds.Length, sounds } );
}
}
public class CreateSoundEventHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.GetProperty( "name" ).GetString();
var subdir = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Sounds";
var fileName = name.EndsWith( ".sound" ) ? name : $"{name}.sound";
var fullPath = Path.Combine( rootPath, subdir, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"Sound already exists: {subdir}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var volume = p.TryGetProperty( "volume", out var v ) ? v.GetSingle() : 1.0f;
var soundJson = JsonSerializer.Serialize( new
{
__version = 0,
Sounds = Array.Empty<object>(),
Volume = volume,
Pitch = 1.0f,
Attenuation = 1.0f
}, new JsonSerializerOptions { WriteIndented = true } );
File.WriteAllText( fullPath, soundJson );
var relativePath = Path.GetRelativePath( rootPath, fullPath ).Replace( '\\', '/' );
return Task.FromResult<object>( new { created = true, path = relativePath } );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 8 — Prefabs
// ═══════════════════════════════════════════════════════════════════
public class CreatePrefabHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var rootPath = Project.Current.GetRootPath();
// If "path" is given use it directly, otherwise fall back to name+directory
string fullPath;
if ( p.TryGetProperty( "path", out var pathProp ) )
{
var prefabRelPath = pathProp.GetString();
fullPath = Path.GetFullPath( Path.Combine( rootPath, prefabRelPath ) );
}
else
{
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : go.Name;
var subdir = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Prefabs";
var fileName = name.EndsWith( ".prefab" ) ? name : $"{name}.prefab";
fullPath = Path.Combine( rootPath, subdir, fileName );
}
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
// Serialize a minimal prefab descriptor referencing the GameObject
var prefabJson = JsonSerializer.Serialize( new
{
__version = 0,
RootObject = new
{
Id = go.Id.ToString(),
Name = go.Name,
Enabled = go.Enabled,
Components = go.Components.GetAll().Select( c => new { Type = c.GetType().Name } ).ToArray()
}
}, new JsonSerializerOptions { WriteIndented = true } );
File.WriteAllText( fullPath, prefabJson );
var relativePath = Path.GetRelativePath( rootPath, fullPath ).Replace( '\\', '/' );
return Task.FromResult<object>( new { created = true, path = relativePath, sourceId = id } );
}
}
public class InstantiatePrefabHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var prefabPath = p.GetProperty( "path" ).GetString();
var rootPath = Project.Current.GetRootPath();
var fullPath = Path.GetFullPath( Path.Combine( rootPath, prefabPath ) );
if ( !File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"Prefab not found: {prefabPath}" } );
try
{
// Read the prefab to get the name
var json = File.ReadAllText( fullPath );
using var doc = JsonDocument.Parse( json );
var prefabName = doc.RootElement
.TryGetProperty( "RootObject", out var ro ) &&
ro.TryGetProperty( "Name", out var nm )
? nm.GetString()
: Path.GetFileNameWithoutExtension( prefabPath );
// Create a new GO mirroring the prefab descriptor
var go = scene.CreateObject( true );
go.Name = prefabName;
if ( p.TryGetProperty( "position", out var pos ) )
go.WorldPosition = ClaudeBridge.ParseVector3( pos );
if ( p.TryGetProperty( "rotation", out var rot ) )
go.WorldRotation = ClaudeBridge.ParseRotation( rot );
return Task.FromResult<object>( new
{
instantiated = true,
prefab = prefabPath,
gameObject = ClaudeBridge.SerializeGo( go ),
note = "Basic instantiation — full prefab resource loading requires s&box prefab asset pipeline"
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to instantiate prefab: {ex.Message}" } );
}
}
}
public class ListPrefabsHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var prefabs = Directory.GetFiles( rootPath, "*.prefab", SearchOption.AllDirectories )
.Select( f => Path.GetRelativePath( rootPath, f ).Replace( '\\', '/' ) )
.ToArray();
return Task.FromResult<object>( new { count = prefabs.Length, prefabs } );
}
}
public class GetPrefabInfoHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var prefabPath = p.GetProperty( "path" ).GetString();
var fullPath = Path.GetFullPath( Path.Combine( rootPath, prefabPath ) );
if ( !File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"Prefab not found: {prefabPath}" } );
var content = File.ReadAllText( fullPath );
var info = new FileInfo( fullPath );
return Task.FromResult<object>( new
{
path = prefabPath,
name = info.Name,
size = info.Length,
modified = info.LastWriteTimeUtc.ToString( "o" ),
content
} );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 9 — Physics
// ═══════════════════════════════════════════════════════════════════
public class AddPhysicsHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var rb = go.GetOrAddComponent<Rigidbody>();
if ( p.TryGetProperty( "gravity", out var g ) ) rb.Gravity = g.GetBoolean();
if ( p.TryGetProperty( "mass", out var m ) ) rb.MassOverride = m.GetSingle();
var colliderType = p.TryGetProperty( "collider", out var ct ) ? ct.GetString() : "box";
var added = new List<string> { "Rigidbody" };
switch ( colliderType.ToLower() )
{
case "sphere":
var sphere = go.GetOrAddComponent<SphereCollider>();
if ( p.TryGetProperty( "radius", out var r ) ) sphere.Radius = r.GetSingle();
added.Add( "SphereCollider" );
break;
case "capsule":
go.GetOrAddComponent<CapsuleCollider>();
added.Add( "CapsuleCollider" );
break;
default: // "box"
var box = go.GetOrAddComponent<BoxCollider>();
if ( p.TryGetProperty( "scale", out var s ) ) box.Scale = ClaudeBridge.ParseVector3( s );
added.Add( "BoxCollider" );
break;
}
return Task.FromResult<object>( new { physicsAdded = true, id, components = added } );
}
}
public class AddColliderHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var colliderType = p.TryGetProperty( "type", out var ct ) ? ct.GetString() : "box";
var isTrigger = p.TryGetProperty( "isTrigger", out var it ) && it.GetBoolean();
string addedType;
switch ( colliderType.ToLower() )
{
case "sphere":
var sphere = go.GetOrAddComponent<SphereCollider>();
if ( p.TryGetProperty( "radius", out var r ) ) sphere.Radius = r.GetSingle();
sphere.IsTrigger = isTrigger;
addedType = "SphereCollider";
break;
case "capsule":
var cap = go.GetOrAddComponent<CapsuleCollider>();
cap.IsTrigger = isTrigger;
addedType = "CapsuleCollider";
break;
case "mesh":
var mesh = go.GetOrAddComponent<HullCollider>();
mesh.IsTrigger = isTrigger;
addedType = "HullCollider";
break;
default: // "box"
var box = go.GetOrAddComponent<BoxCollider>();
if ( p.TryGetProperty( "scale", out var s ) ) box.Scale = ClaudeBridge.ParseVector3( s );
box.IsTrigger = isTrigger;
addedType = "BoxCollider";
break;
}
return Task.FromResult<object>( new { added = true, id, collider = addedType, isTrigger } );
}
}
public class RaycastHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var start = ClaudeBridge.ParseVector3( p.GetProperty( "start" ) );
var end = ClaudeBridge.ParseVector3( p.GetProperty( "end" ) );
try
{
var tr = scene.Trace.Ray( start, end ).Run();
return Task.FromResult<object>( new
{
hit = tr.Hit,
hitPosition = tr.Hit ? new { tr.HitPosition.x, tr.HitPosition.y, tr.HitPosition.z } : null,
normal = tr.Hit ? new { tr.Normal.x, tr.Normal.y, tr.Normal.z } : null,
distance = tr.Distance,
gameObjectId = tr.GameObject?.Id.ToString(),
gameObjectName = tr.GameObject?.Name
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Raycast failed: {ex.Message}" } );
}
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 10 — Code templates
// ═══════════════════════════════════════════════════════════════════
public class CreatePlayerControllerHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "PlayerController";
var directory = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Code";
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
var fullPath = Path.Combine( rootPath, directory, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var className = Path.GetFileNameWithoutExtension( fileName );
var code = $@"using Sandbox;
public sealed class {className} : Component
{{
[Property] public float MoveSpeed {{ get; set; }} = 200f;
[Property] public float JumpForce {{ get; set; }} = 400f;
private CharacterController _controller;
protected override void OnStart()
{{
_controller = GetOrAddComponent<CharacterController>();
}}
protected override void OnUpdate()
{{
if ( _controller == null ) return;
var move = new Vector3(
Input.AnalogMove.x,
0,
Input.AnalogMove.y
) * MoveSpeed;
if ( _controller.IsOnGround && Input.Pressed( ""jump"" ) )
_controller.Punch( Vector3.Up * JumpForce );
_controller.Accelerate( move );
_controller.ApplyFriction( 10f );
_controller.Move();
}}
}}
";
File.WriteAllText( fullPath, code );
return Task.FromResult<object>( new { created = true, path = $"{directory}/{fileName}", className } );
}
}
public class CreateNpcControllerHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "NpcController";
var directory = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Code";
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
var fullPath = Path.Combine( rootPath, directory, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var className = Path.GetFileNameWithoutExtension( fileName );
var code = $@"using Sandbox;
public sealed class {className} : Component
{{
[Property] public float MoveSpeed {{ get; set; }} = 100f;
[Property] public float DetectRadius {{ get; set; }} = 500f;
[Property] public GameObject Target {{ get; set; }}
private NavMeshAgent _agent;
protected override void OnStart()
{{
_agent = GetOrAddComponent<NavMeshAgent>();
}}
protected override void OnUpdate()
{{
if ( Target == null || _agent == null ) return;
float dist = Vector3.DistanceBetween( WorldPosition, Target.WorldPosition );
if ( dist < DetectRadius )
_agent.MoveTo( Target.WorldPosition );
}}
}}
";
File.WriteAllText( fullPath, code );
return Task.FromResult<object>( new { created = true, path = $"{directory}/{fileName}", className } );
}
}
public class CreateGameManagerHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "GameManager";
var directory = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Code";
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
var fullPath = Path.Combine( rootPath, directory, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var className = Path.GetFileNameWithoutExtension( fileName );
var code = $@"using Sandbox;
public sealed class {className} : Component, Component.INetworkListener
{{
public static {className} Instance {{ get; private set; }}
[Property] public int MaxPlayers {{ get; set; }} = 16;
[Property] public string GameState {{ get; set; }} = ""waiting"";
protected override void OnStart()
{{
Instance = this;
Log.Info( $""[{className}] Started. State: {{GameState}}"" );
}}
protected override void OnDestroy()
{{
if ( Instance == this ) Instance = null;
}}
public void OnActive( Connection channel )
{{
Log.Info( $""[{className}] Player connected: {{channel.DisplayName}}"" );
}}
}}
";
File.WriteAllText( fullPath, code );
return Task.FromResult<object>( new { created = true, path = $"{directory}/{fileName}", className } );
}
}
public class CreateTriggerZoneHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "TriggerZone";
var directory = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Code";
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
var fullPath = Path.Combine( rootPath, directory, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var className = Path.GetFileNameWithoutExtension( fileName );
var code = $@"using Sandbox;
public sealed class {className} : Component, Component.ITriggerListener
{{
[Property] public string TriggerTag {{ get; set; }} = ""player"";
protected override void OnStart()
{{
var collider = GetOrAddComponent<BoxCollider>();
collider.IsTrigger = true;
}}
public void OnTriggerEnter( Collider other )
{{
if ( other.GameObject.Tags.Has( TriggerTag ) )
OnPlayerEnter( other.GameObject );
}}
public void OnTriggerExit( Collider other )
{{
if ( other.GameObject.Tags.Has( TriggerTag ) )
OnPlayerExit( other.GameObject );
}}
private void OnPlayerEnter( GameObject player )
{{
Log.Info( $""[{className}] {{player.Name}} entered trigger"" );
}}
private void OnPlayerExit( GameObject player )
{{
Log.Info( $""[{className}] {{player.Name}} exited trigger"" );
}}
}}
";
File.WriteAllText( fullPath, code );
return Task.FromResult<object>( new { created = true, path = $"{directory}/{fileName}", className } );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 11 — UI
// ═══════════════════════════════════════════════════════════════════
public class CreateRazorUIHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.GetProperty( "name" ).GetString();
var directory = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "UI";
var fileName = name.EndsWith( ".razor" ) ? name : $"{name}.razor";
var fullPath = Path.Combine( rootPath, directory, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var componentName = Path.GetFileNameWithoutExtension( fileName );
var razor = $@"@using Sandbox;
@using Sandbox.UI;
@namespace {componentName}
<root class=""{componentName.ToLower()}"">
<div class=""container"">
<label>@Title</label>
</div>
</root>
@code {{
[Property] public string Title {{ get; set; }} = ""{componentName}"";
}}
";
File.WriteAllText( fullPath, razor );
var relativePath = Path.GetRelativePath( rootPath, fullPath ).Replace( '\\', '/' );
return Task.FromResult<object>( new { created = true, path = relativePath, componentName } );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 12 — Networking
// ═══════════════════════════════════════════════════════════════════
public class NetworkSpawnHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
try
{
go.NetworkSpawn();
return Task.FromResult<object>( new { spawned = true, id } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"NetworkSpawn failed: {ex.Message}" } );
}
}
}
public class AddSyncPropertyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var filePath = p.GetProperty( "path" ).GetString();
var propertyName = p.GetProperty( "propertyName" ).GetString();
var propertyType = p.TryGetProperty( "propertyType", out var ptProp ) ? ptProp.GetString() ?? "float" : "float";
var defaultValue = p.TryGetProperty( "defaultValue", out var dvProp ) ? dvProp.GetString() : null;
var fullPath = Path.GetFullPath( Path.Combine( rootPath, filePath ) );
if ( !fullPath.StartsWith( rootPath, StringComparison.OrdinalIgnoreCase ) )
return Task.FromResult<object>( new { error = "Path traversal denied" } );
if ( !File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File not found: {filePath}" } );
var content = File.ReadAllText( fullPath );
// Find the property declaration and add [Sync] above it if not already present
var propPattern = $"public ";
var propIndex = content.IndexOf( $"public ", StringComparison.Ordinal );
// More targeted: find the specific property
var searchStr = $"public.*{propertyName}";
var lines = content.Split( '\n' ).ToList();
bool modified = false;
for ( int i = 0; i < lines.Count; i++ )
{
if ( lines[i].Contains( propertyName ) && lines[i].Contains( "public" ) && lines[i].Contains( "{" ) )
{
if ( i > 0 && lines[i - 1].TrimStart().StartsWith( "[Sync]" ) )
{
return Task.FromResult<object>( new { error = $"Property '{propertyName}' already has [Sync]" } );
}
var indent = new string( '\t', lines[i].TakeWhile( c => c == '\t' ).Count() );
lines.Insert( i, $"{indent}[Sync]" );
modified = true;
break;
}
}
if ( !modified )
return Task.FromResult<object>( new { error = $"Property '{propertyName}' not found in file" } );
File.WriteAllText( fullPath, string.Join( '\n', lines ) );
return Task.FromResult<object>( new { added = true, path = filePath, property = propertyName, attribute = "[Sync]" } );
}
}
public class AddRpcMethodHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var filePath = p.GetProperty( "path" ).GetString();
var methodName = p.TryGetProperty( "methodName", out var m ) ? m.GetString() : "MyRpc";
var rpcType = p.TryGetProperty( "rpcType", out var rt ) ? rt.GetString() : "Broadcast";
var fullPath = Path.GetFullPath( Path.Combine( rootPath, filePath ) );
if ( !fullPath.StartsWith( rootPath, StringComparison.OrdinalIgnoreCase ) )
return Task.FromResult<object>( new { error = "Path traversal denied" } );
if ( !File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File not found: {filePath}" } );
var content = File.ReadAllText( fullPath );
// Insert new RPC method before the last closing brace of the class
var lastBrace = content.LastIndexOf( '}' );
if ( lastBrace < 0 )
return Task.FromResult<object>( new { error = "Could not find closing brace in file" } );
var rpcAttr = rpcType.ToLower() switch
{
"owner" => "[Rpc.Owner]",
"host" => "[Rpc.Host]",
_ => "[Rpc.Broadcast]"
};
var methodCode = $"\n\t{rpcAttr}\n\tpublic void {methodName}()\n\t{{\n\t\t// TODO: implement RPC\n\t}}\n";
content = content.Insert( lastBrace, methodCode );
File.WriteAllText( fullPath, content );
return Task.FromResult<object>( new { added = true, path = filePath, method = methodName, attribute = rpcAttr } );
}
}
public class CreateNetworkedPlayerHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "NetworkedPlayer";
var directory = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Code";
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
var fullPath = Path.Combine( rootPath, directory, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var className = Path.GetFileNameWithoutExtension( fileName );
var code = $@"using Sandbox;
public sealed class {className} : Component
{{
[Sync] public string PlayerName {{ get; set; }}
[Sync] public int Health {{ get; set; }} = 100;
[Property] public float MoveSpeed {{ get; set; }} = 200f;
private CharacterController _controller;
protected override void OnStart()
{{
_controller = GetOrAddComponent<CharacterController>();
if ( IsProxy ) return;
PlayerName = Connection.Local.DisplayName;
Health = 100;
}}
protected override void OnUpdate()
{{
if ( IsProxy || _controller == null ) return;
var move = new Vector3(
Input.AnalogMove.x,
0,
Input.AnalogMove.y
) * MoveSpeed;
_controller.Accelerate( move );
_controller.ApplyFriction( 10f );
_controller.Move();
}}
[Rpc.Broadcast]
public void TakeDamage( int amount )
{{
Health -= amount;
if ( Health <= 0 )
Log.Info( $""{{PlayerName}} died!"" );
}}
}}
";
File.WriteAllText( fullPath, code );
return Task.FromResult<object>( new { created = true, path = $"{directory}/{fileName}", className } );
}
}
public class CreateLobbyManagerHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "LobbyManager";
var directory = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Code";
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
var fullPath = Path.Combine( rootPath, directory, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var className = Path.GetFileNameWithoutExtension( fileName );
var code = $@"using Sandbox;
using System.Collections.Generic;
public sealed class {className} : Component, Component.INetworkListener
{{
public static {className} Instance {{ get; private set; }}
[Sync] public int PlayerCount {{ get; private set; }}
[Property] public int MaxPlayers {{ get; set; }} = 16;
[Property] public string LobbyState {{ get; set; }} = ""waiting"";
private readonly List<Connection> _players = new();
protected override void OnStart()
{{
Instance = this;
}}
protected override void OnDestroy()
{{
if ( Instance == this ) Instance = null;
}}
public void OnActive( Connection channel )
{{
_players.Add( channel );
PlayerCount = _players.Count;
Log.Info( $""[{className}] {{channel.DisplayName}} joined. Players: {{PlayerCount}}/{{MaxPlayers}}"" );
if ( PlayerCount >= MaxPlayers )
StartGame();
}}
public void OnDisconnected( Connection channel )
{{
_players.Remove( channel );
PlayerCount = _players.Count;
}}
private void StartGame()
{{
LobbyState = ""playing"";
Log.Info( $""[{className}] Game starting!"" );
}}
}}
";
File.WriteAllText( fullPath, code );
return Task.FromResult<object>( new { created = true, path = $"{directory}/{fileName}", className } );
}
}
public class CreateNetworkEventsHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "NetworkEvents";
var directory = p.TryGetProperty( "directory", out var d ) ? d.GetString() : "Code";
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
var fullPath = Path.Combine( rootPath, directory, fileName );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
var className = Path.GetFileNameWithoutExtension( fileName );
var code = $@"using Sandbox;
public sealed class {className} : Component
{{
/// <summary>Broadcasts a named event to all connected clients.</summary>
[Rpc.Broadcast]
public void SendEvent( string eventName, string payload )
{{
Log.Info( $""[{className}] Event '{{eventName}}' received with payload: {{payload}}"" );
OnNetworkEvent( eventName, payload );
}}
/// <summary>Sends an event only to the host.</summary>
[Rpc.Host]
public void SendEventToHost( string eventName, string payload )
{{
Log.Info( $""[{className}] Host received event '{{eventName}}'"" );
OnNetworkEvent( eventName, payload );
}}
private void OnNetworkEvent( string eventName, string payload )
{{
// Dispatch locally — extend this switch to handle specific events
switch ( eventName )
{{
case ""player_scored"":
Log.Info( $""Player scored: {{payload}}"" );
break;
default:
Log.Info( $""Unhandled event: {{eventName}}"" );
break;
}}
}}
}}
";
File.WriteAllText( fullPath, code );
return Task.FromResult<object>( new { created = true, path = $"{directory}/{fileName}", className } );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 13 — Publishing / config
// ═══════════════════════════════════════════════════════════════════
public class GetProjectConfigHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var sbproj = Directory.GetFiles( rootPath, "*.sbproj", SearchOption.TopDirectoryOnly ).FirstOrDefault();
if ( sbproj == null )
return Task.FromResult<object>( new { error = ".sbproj file not found in project root" } );
var content = File.ReadAllText( sbproj );
return Task.FromResult<object>( new
{
path = Path.GetRelativePath( rootPath, sbproj ).Replace( '\\', '/' ),
content,
project = new
{
title = Project.Current.Config.Title,
org = Project.Current.Config.Org,
ident = Project.Current.Config.Ident,
type = Project.Current.Config.Type
}
} );
}
}
public class SetProjectConfigHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var sbproj = Directory.GetFiles( rootPath, "*.sbproj", SearchOption.TopDirectoryOnly ).FirstOrDefault();
if ( sbproj == null )
return Task.FromResult<object>( new { error = ".sbproj file not found in project root" } );
var content = File.ReadAllText( sbproj );
// Apply find/replace pairs from the "changes" object
if ( p.TryGetProperty( "changes", out var changes ) && changes.ValueKind == JsonValueKind.Object )
{
foreach ( var change in changes.EnumerateObject() )
{
// Replace JSON string values by key name pattern
var searchPattern = $"\"{change.Name}\":";
var idx = content.IndexOf( searchPattern, StringComparison.OrdinalIgnoreCase );
if ( idx >= 0 )
{
// find the value start
var valueStart = content.IndexOf( '"', idx + searchPattern.Length );
var valueEnd = content.IndexOf( '"', valueStart + 1 );
if ( valueStart >= 0 && valueEnd > valueStart )
{
content = content.Substring( 0, valueStart + 1 )
+ change.Value.GetString()
+ content.Substring( valueEnd );
}
}
}
File.WriteAllText( sbproj, content );
}
else if ( p.TryGetProperty( "content", out var newContent ) )
{
File.WriteAllText( sbproj, newContent.GetString() );
}
else
{
return Task.FromResult<object>( new { error = "Provide 'changes' object or 'content' string" } );
}
return Task.FromResult<object>( new { updated = true, path = Path.GetRelativePath( rootPath, sbproj ).Replace( '\\', '/' ) } );
}
}
public class ValidateProjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var issues = new List<string>();
var checks = new List<object>();
// Check for .sbproj
var sbproj = Directory.GetFiles( rootPath, "*.sbproj", SearchOption.TopDirectoryOnly ).FirstOrDefault();
var hasSbproj = sbproj != null;
checks.Add( new { check = "sbproj_exists", pass = hasSbproj, detail = hasSbproj ? sbproj : "No .sbproj found" } );
if ( !hasSbproj ) issues.Add( "Missing .sbproj file" );
// Check for at least one scene
var sceneCount = Directory.GetFiles( rootPath, "*.scene", SearchOption.AllDirectories ).Length;
checks.Add( new { check = "has_scenes", pass = sceneCount > 0, detail = $"{sceneCount} scene(s) found" } );
if ( sceneCount == 0 ) issues.Add( "No .scene files found" );
// Check project ident
var hasIdent = !string.IsNullOrEmpty( Project.Current.Config.Ident );
checks.Add( new { check = "has_ident", pass = hasIdent, detail = hasIdent ? Project.Current.Config.Ident : "No ident set" } );
if ( !hasIdent ) issues.Add( "Project Ident not set" );
// Check project title
var hasTitle = !string.IsNullOrEmpty( Project.Current.Config.Title );
checks.Add( new { check = "has_title", pass = hasTitle, detail = hasTitle ? Project.Current.Config.Title : "No title set" } );
if ( !hasTitle ) issues.Add( "Project Title not set" );
var valid = issues.Count == 0;
return Task.FromResult<object>( new { valid, issueCount = issues.Count, issues, checks } );
}
}
public class SetProjectThumbnailHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var rootPath = Project.Current.GetRootPath();
var sourcePath = p.GetProperty( "sourcePath" ).GetString();
var fullSource = Path.GetFullPath( Path.Combine( rootPath, sourcePath ) );
if ( !File.Exists( fullSource ) )
return Task.FromResult<object>( new { error = $"Source image not found: {sourcePath}" } );
var ext = Path.GetExtension( fullSource ).ToLower();
if ( ext != ".png" && ext != ".jpg" && ext != ".jpeg" )
return Task.FromResult<object>( new { error = "Thumbnail must be a .png or .jpg file" } );
var thumbDest = Path.Combine( rootPath, "thumb.png" );
File.Copy( fullSource, thumbDest, overwrite: true );
return Task.FromResult<object>( new { set = true, thumbnail = "thumb.png" } );
}
}
// ═══════════════════════════════════════════════════════════════════
// BATCH 15 — New handlers (joints, sound, UI panels, undo/redo,
// networking helpers, packages, assets, screenshot, hotload)
// ═══════════════════════════════════════════════════════════════════
public class AddJointHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var jointType = p.TryGetProperty( "type", out var jt ) ? jt.GetString() : "fixed";
// Resolve optional target body
GameObject targetGo = null;
if ( p.TryGetProperty( "targetId", out var tid ) && Guid.TryParse( tid.GetString(), out var targetGuid ) )
targetGo = scene.Directory.FindByGuid( targetGuid );
try
{
string addedType;
switch ( jointType?.ToLower() )
{
case "spring":
{
var joint = go.AddComponent<SpringJoint>();
if ( targetGo != null ) joint.Body = targetGo;
if ( p.TryGetProperty( "frequency", out var freq ) ) joint.Frequency = freq.GetSingle();
if ( p.TryGetProperty( "damping", out var damp ) ) joint.Damping = damp.GetSingle();
addedType = "SpringJoint";
break;
}
case "hinge":
{
var joint = go.AddComponent<HingeJoint>();
if ( targetGo != null ) joint.Body = targetGo;
addedType = "HingeJoint";
break;
}
case "slider":
{
var joint = go.AddComponent<SliderJoint>();
if ( targetGo != null ) joint.Body = targetGo;
addedType = "SliderJoint";
break;
}
default: // "fixed"
{
var joint = go.AddComponent<FixedJoint>();
if ( targetGo != null ) joint.Body = targetGo;
addedType = "FixedJoint";
break;
}
}
return Task.FromResult<object>( new { added = true, id, joint = addedType, targetId = targetGo?.Id.ToString() } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to add joint: {ex.Message}" } );
}
}
}
public class AssignSoundHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var soundPath = p.GetProperty( "sound" ).GetString();
var playOnStart = p.TryGetProperty( "playOnStart", out var pos ) && pos.GetBoolean();
try
{
var spc = go.GetOrAddComponent<SoundPointComponent>();
// Load the SoundEvent from the path and assign it
var soundEvent = ResourceLibrary.Get<SoundEvent>( soundPath );
if ( soundEvent != null )
spc.SoundEvent = soundEvent;
if ( playOnStart )
spc.StartSound();
return Task.FromResult<object>( new
{
assigned = true,
id,
sound = soundPath,
soundLoaded = soundEvent != null,
playOnStart
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to assign sound: {ex.Message}" } );
}
}
}
public class PlaySoundPreviewHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var eventName = p.GetProperty( "sound" ).GetString();
var volume = p.TryGetProperty( "volume", out var v ) ? v.GetSingle() : 1.0f;
try
{
var handle = Sound.Play( eventName );
return Task.FromResult<object>( new { playing = true, sound = eventName, volume } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to play sound: {ex.Message}" } );
}
}
}
public class SetMaterialPropertyHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var renderer = go.GetComponent<ModelRenderer>();
if ( renderer == null )
return Task.FromResult<object>( new { error = "No ModelRenderer on GameObject" } );
var propertyName = p.GetProperty( "property" ).GetString();
var value = p.GetProperty( "value" );
try
{
// Ensure we have a mutable material override
var mat = renderer.MaterialOverride;
if ( mat == null )
return Task.FromResult<object>( new { error = "No MaterialOverride set — assign a material first via assign_material" } );
// Apply the property based on the JSON value kind
switch ( value.ValueKind )
{
case JsonValueKind.Number:
mat.Set( propertyName, value.GetSingle() );
break;
case JsonValueKind.True:
case JsonValueKind.False:
mat.Set( propertyName, value.GetBoolean() ? 1f : 0f );
break;
case JsonValueKind.Object:
// Try to interpret as Color (r,g,b,a) or Vector3 (x,y,z)
if ( value.TryGetProperty( "r", out var cr ) )
{
float r = cr.GetSingle();
float g = value.TryGetProperty( "g", out var cg ) ? cg.GetSingle() : 0f;
float b = value.TryGetProperty( "b", out var cb ) ? cb.GetSingle() : 0f;
float a = value.TryGetProperty( "a", out var ca ) ? ca.GetSingle() : 1f;
mat.Set( propertyName, new Color( r, g, b, a ) );
}
else
{
float x = value.TryGetProperty( "x", out var vx ) ? vx.GetSingle() : 0f;
float y = value.TryGetProperty( "y", out var vy ) ? vy.GetSingle() : 0f;
float z = value.TryGetProperty( "z", out var vz ) ? vz.GetSingle() : 0f;
mat.Set( propertyName, new Vector3( x, y, z ) );
}
break;
default:
mat.Set( propertyName, value.GetString() );
break;
}
return Task.FromResult<object>( new { set = true, id, property = propertyName } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to set material property: {ex.Message}" } );
}
}
}
public class AddScreenPanelHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "Screen Panel";
var zIndex = p.TryGetProperty( "zIndex", out var zi ) ? zi.GetInt32() : 0;
// Resolve optional parent
GameObject parentGo = null;
if ( p.TryGetProperty( "parent", out var par ) && Guid.TryParse( par.GetString(), out var parGuid ) )
parentGo = scene.Directory.FindByGuid( parGuid );
try
{
var go = scene.CreateObject( true );
go.Name = name;
if ( parentGo != null )
go.SetParent( parentGo, false );
var panel = go.AddComponent<ScreenPanel>();
panel.ZIndex = zIndex;
// Optionally add a named panel component type
if ( p.TryGetProperty( "panelComponent", out var pc ) )
{
var typeName = pc.GetString();
if ( !string.IsNullOrEmpty( typeName ) )
{
var typeDesc = Game.TypeLibrary.GetType( typeName );
if ( typeDesc != null )
go.Components.Create( typeDesc );
}
}
return Task.FromResult<object>( new { created = true, gameObject = ClaudeBridge.SerializeGo( go ) } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to add ScreenPanel: {ex.Message}" } );
}
}
}
public class AddWorldPanelHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : "World Panel";
var lookAtCamera = p.TryGetProperty( "lookAtCamera", out var lac ) && lac.GetBoolean();
// Resolve optional parent
GameObject parentGo = null;
if ( p.TryGetProperty( "parent", out var par ) && Guid.TryParse( par.GetString(), out var parGuid ) )
parentGo = scene.Directory.FindByGuid( parGuid );
try
{
var go = scene.CreateObject( true );
go.Name = name;
if ( parentGo != null )
go.SetParent( parentGo, false );
if ( p.TryGetProperty( "position", out var pos ) )
go.WorldPosition = ClaudeBridge.ParseVector3( pos );
if ( p.TryGetProperty( "rotation", out var rot ) )
go.WorldRotation = ClaudeBridge.ParseRotation( rot );
if ( p.TryGetProperty( "worldScale", out var ws ) )
go.WorldScale = ClaudeBridge.ParseVector3( ws );
var panel = go.AddComponent<WorldPanel>();
panel.LookAtCamera = lookAtCamera;
// Optionally add a named panel component type
if ( p.TryGetProperty( "panelComponent", out var pc ) )
{
var typeName = pc.GetString();
if ( !string.IsNullOrEmpty( typeName ) )
{
var typeDesc = Game.TypeLibrary.GetType( typeName );
if ( typeDesc != null )
go.Components.Create( typeDesc );
}
}
return Task.FromResult<object>( new { created = true, gameObject = ClaudeBridge.SerializeGo( go ) } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to add WorldPanel: {ex.Message}" } );
}
}
}
public class UndoHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
SceneEditorSession.Active?.UndoSystem?.Undo();
return Task.FromResult<object>( new { undone = true } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Undo failed: {ex.Message}" } );
}
}
}
public class RedoHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
SceneEditorSession.Active?.UndoSystem?.Redo();
return Task.FromResult<object>( new { redone = true } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Redo failed: {ex.Message}" } );
}
}
}
public class AddNetworkHelperHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : null;
if ( name != null ) go.Name = name;
try
{
var helper = go.GetOrAddComponent<NetworkHelper>();
helper.StartServer = true;
return Task.FromResult<object>( new { added = true, id, component = "NetworkHelper" } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to add NetworkHelper: {ex.Message}" } );
}
}
}
public class ConfigureNetworkHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
// Networking.MaxPlayers is read-only — set via lobby config
if ( p.TryGetProperty( "lobbyName", out var ln ) ) Networking.ServerName = ln.GetString();
return Task.FromResult<object>( new
{
configured = true,
maxPlayers = Networking.MaxPlayers,
serverName = Networking.ServerName
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to configure network: {ex.Message}" } );
}
}
}
public class GetNetworkStatusHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
return Task.FromResult<object>( new
{
isActive = Networking.IsActive,
isHost = Networking.IsHost,
isClient = Networking.IsClient,
isConnecting = Networking.IsConnecting,
maxPlayers = Networking.MaxPlayers,
serverName = Networking.ServerName
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to get network status: {ex.Message}" } );
}
}
}
public class SetOwnershipHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var id = p.GetProperty( "id" ).GetString();
if ( !Guid.TryParse( id, out var guid ) )
return Task.FromResult<object>( new { error = "Invalid GUID" } );
var go = scene.Directory.FindByGuid( guid );
if ( go == null )
return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );
var connectionId = p.TryGetProperty( "connectionId", out var cid ) ? cid.GetString() : null;
try
{
if ( string.IsNullOrEmpty( connectionId ) )
{
go.Network.DropOwnership();
return Task.FromResult<object>( new { ownershipDropped = true, id } );
}
else
{
// Find connection by steam ID or display name
var conn = Connection.All.FirstOrDefault( c =>
c.SteamId.ToString() == connectionId ||
c.Id.ToString() == connectionId );
if ( conn == null )
return Task.FromResult<object>( new { error = $"Connection not found: {connectionId}" } );
go.Network.AssignOwnership( conn );
return Task.FromResult<object>( new { ownershipAssigned = true, id, connectionId } );
}
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to set ownership: {ex.Message}" } );
}
}
}
public class GetPackageDetailsHandler : IBridgeHandler
{
public async Task<object> Execute( JsonElement p )
{
var ident = p.GetProperty( "ident" ).GetString();
try
{
var pkg = await Package.FetchAsync( ident, false );
if ( pkg == null )
return new { error = $"Package not found: {ident}" };
return new
{
fullIdent = pkg.FullIdent,
title = pkg.Title,
summary = pkg.Summary,
description = pkg.Description,
org = pkg.Org
};
}
catch ( Exception ex )
{
return new { error = $"Failed to fetch package: {ex.Message}" };
}
}
}
public class InstallAssetHandler : IBridgeHandler
{
public async Task<object> Execute( JsonElement p )
{
var ident = p.GetProperty( "ident" ).GetString();
try
{
var asset = await AssetSystem.InstallAsync( ident, true );
if ( asset == null )
return new { error = $"Failed to install asset: {ident}" };
return new
{
installed = true,
ident,
name = asset.Name,
path = asset.Path,
relativePath = asset.RelativePath
};
}
catch ( Exception ex )
{
return new { error = $"Failed to install asset: {ex.Message}" };
}
}
}
public class ListAssetLibraryHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var query = p.TryGetProperty( "query", out var q ) ? q.GetString() : null;
var typeFilter = p.TryGetProperty( "type", out var tf ) ? tf.GetString() : null;
var maxResults = p.TryGetProperty( "maxResults", out var mr ) ? mr.GetInt32() : 200;
try
{
var assets = AssetSystem.All
.Where( a => query == null || a.Name.Contains( query, StringComparison.OrdinalIgnoreCase ) )
.Where( a => typeFilter == null || a.AssetType?.ToString().Contains( typeFilter, StringComparison.OrdinalIgnoreCase ) == true )
.Take( maxResults )
.Select( a => new
{
name = a.Name,
path = a.Path,
relativePath = a.RelativePath,
assetType = a.AssetType?.ToString()
} )
.ToArray();
return Task.FromResult<object>( new { count = assets.Length, assets } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to list asset library: {ex.Message}" } );
}
}
}
public class TakeScreenshotHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var path = p.TryGetProperty( "path", out var pt ) ? pt.GetString() : null;
try
{
EditorScene.TakeHighResScreenshot( 1920, 1080 );
return Task.FromResult<object>( new
{
taken = true,
note = "Screenshot taken via EditorScene.TakeHighResScreenshot(1920, 1080)",
path = path ?? "<default editor location>"
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to take screenshot: {ex.Message}" } );
}
}
}
public class TriggerHotloadHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
return Task.FromResult<object>( new
{
message = "Hotload is automatic in s&box when files change. Save a .cs file to trigger recompilation.",
note = "No manual hotload API is available. Modify a script file to trigger a hotload."
} );
}
}
// ═══════════════════════════════════════════════════════════════════
// Main-thread poller — ensures scene APIs run on the editor thread
// ═══════════════════════════════════════════════════════════════════
[Dock( "Editor", "Claude Bridge", "smart_toy" )]
public class BridgePoller : Widget
{
public BridgePoller( Widget parent ) : base( parent )
{
MinimumSize = new Vector2( 200, 80 );
WindowTitle = "Claude Bridge";
Layout = Layout.Column();
Layout.Margin = 8;
Layout.Spacing = 4;
var title = Layout.Add( new Label( "Claude Bridge", this ) );
title.SetStyles( "font-size: 14px; font-weight: bold; color: white;" );
var status = Layout.Add( new Label( $"Handlers: {ClaudeBridge.HandlerCount} | IPC Active", this ) );
status.SetStyles( "font-size: 11px; color: #aaa;" );
Layout.AddSpacingCell( 8 );
var credit = Layout.Add( new Label( "A project by sboxskins.gg", this ) );
credit.SetStyles( "font-size: 11px; color: #4fc3f7;" );
var url = Layout.Add( new Label( "https://sboxskins.gg", this ) );
url.SetStyles( "font-size: 10px; color: #888;" );
}
[EditorEvent.Frame]
public void OnFrame()
{
ClaudeBridge.ProcessPendingOnMainThread();
}
}
/// <summary>
/// Generates a smooth heightmap terrain mesh via MCP.
/// Params: size (float), resolution (int), hills (array of {x,y,radius,height}),
/// clearings (array of {x,y,radius}), name (string)
/// </summary>
public class BuildTerrainMeshHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
var size = p.TryGetProperty( "size", out var sz ) ? sz.GetSingle() : 9600f;
var resolution = p.TryGetProperty( "resolution", out var res ) ? res.GetInt32() : 64;
var name = p.TryGetProperty( "name", out var nm ) ? nm.GetString() : "Generated Terrain";
// Parse hills: [{x, y, radius, height}]
var hills = new System.Collections.Generic.List<(Vector2 pos, float radius, float height)>();
if ( p.TryGetProperty( "hills", out var hillsArr ) && hillsArr.ValueKind == JsonValueKind.Array )
{
foreach ( var h in hillsArr.EnumerateArray() )
{
var hx = h.TryGetProperty( "x", out var hxp ) ? hxp.GetSingle() : 0;
var hy = h.TryGetProperty( "y", out var hyp ) ? hyp.GetSingle() : 0;
var hr = h.TryGetProperty( "radius", out var hrp ) ? hrp.GetSingle() : 500;
var hh = h.TryGetProperty( "height", out var hhp ) ? hhp.GetSingle() : 100;
hills.Add( (new Vector2( hx, hy ), hr, hh) );
}
}
// Parse clearings: [{x, y, radius}]
var clearings = new System.Collections.Generic.List<(Vector2 pos, float radius)>();
if ( p.TryGetProperty( "clearings", out var clArr ) && clArr.ValueKind == JsonValueKind.Array )
{
foreach ( var c in clArr.EnumerateArray() )
{
var cx = c.TryGetProperty( "x", out var cxp ) ? cxp.GetSingle() : 0;
var cy = c.TryGetProperty( "y", out var cyp ) ? cyp.GetSingle() : 0;
var cr = c.TryGetProperty( "radius", out var crp ) ? crp.GetSingle() : 300;
clearings.Add( (new Vector2( cx, cy ), cr) );
}
}
var go = scene.CreateObject( true );
go.Name = name;
go.WorldPosition = Vector3.Zero;
var mesh = go.AddComponent<MeshComponent>();
// MeshComponent.Mesh is null on a freshly-added component — must assign a fresh PolygonMesh
if ( mesh.Mesh == null ) mesh.Mesh = new PolygonMesh();
var polyMesh = mesh.Mesh;
var halfSize = size * 0.5f;
var step = size / resolution;
var stride = resolution + 1;
// Generate heightmap vertices
var handles = new HalfEdgeMesh.VertexHandle[stride * stride];
for ( int z = 0; z <= resolution; z++ )
{
for ( int x = 0; x <= resolution; x++ )
{
var worldX = -halfSize + x * step;
var worldY = -halfSize + z * step;
var height = CalcHeight( worldX, worldY, hills, clearings );
handles[z * stride + x] = polyMesh.AddVertex( new Vector3( worldX, worldY, height ) );
}
}
// Generate quad faces
int faceCount = 0;
for ( int z = 0; z < resolution; z++ )
{
for ( int x = 0; x < resolution; x++ )
{
var tl = z * stride + x;
var tr = tl + 1;
var bl = (z + 1) * stride + x;
var br = bl + 1;
polyMesh.AddFace( new[] { handles[tl], handles[bl], handles[br], handles[tr] } );
faceCount++;
}
}
return Task.FromResult<object>( new
{
built = true,
id = go.Id.ToString(),
name = go.Name,
vertices = handles.Length,
faces = faceCount
} );
}
private static float CalcHeight( float x, float y,
System.Collections.Generic.List<(Vector2 pos, float radius, float height)> hills,
System.Collections.Generic.List<(Vector2 pos, float radius)> clearings )
{
float height = 0f;
var pos = new Vector2( x, y );
// Hills with smooth cosine falloff
foreach ( var (hillPos, radius, hillHeight) in hills )
{
var dist = Vector2.DistanceBetween( pos, hillPos );
if ( dist < radius )
{
var t = dist / radius;
var blend = (MathF.Cos( t * MathF.PI ) + 1f) * 0.5f;
height += hillHeight * blend;
}
}
// Flatten clearings
foreach ( var (clearPos, clearRadius) in clearings )
{
var dist = Vector2.DistanceBetween( pos, clearPos );
if ( dist < clearRadius )
{
var t = dist / clearRadius;
var blend = (MathF.Cos( t * MathF.PI ) + 1f) * 0.5f;
height = MathX.Lerp( height, 0f, blend );
}
}
// Subtle noise
var ix = (int)MathF.Floor( x * 0.001f );
var iy = (int)MathF.Floor( y * 0.001f );
var fx = x * 0.001f - ix;
var fy = y * 0.001f - iy;
fx = fx * fx * (3f - 2f * fx);
fy = fy * fy * (3f - 2f * fy);
float Hash( int px, int py ) { var n = px * 127 + py * 311; n = (n << 13) ^ n; return 1f - ((n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff) / 1073741824f; }
var a = Hash( ix, iy ); var b = Hash( ix + 1, iy ); var c = Hash( ix, iy + 1 ); var d = Hash( ix + 1, iy + 1 );
height += MathX.Lerp( MathX.Lerp( a, b, fx ), MathX.Lerp( c, d, fx ), fy ) * 25f;
return height;
}
}
// ════════════════════════════════════════════════════════════════════════
// New handlers — Map editing, sculpt, type discovery (Batch 15 + 16)
// ════════════════════════════════════════════════════════════════════════
/// <summary>
/// Shared helpers for world-gen and reflection-driven handlers.
/// </summary>
internal static class WorldGenHelpers
{
public static Component FindFirstComponent( string typeName, out string error )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null ) { error = "No active scene"; return null; }
foreach ( var go in scene.GetAllObjects( false ) )
{
foreach ( var comp in go.Components.GetAll() )
{
if ( comp.GetType().Name == typeName ) { error = null; return comp; }
}
}
error = $"No component of type '{typeName}' found in scene"; return null;
}
public static Component ResolveComponent( JsonElement p, string defaultType, out string error )
{
string typeName = p.TryGetProperty( "component", out var c ) ? c.GetString() : defaultType;
if ( p.TryGetProperty( "id", out var idEl ) && idEl.ValueKind == JsonValueKind.String )
{
var idStr = idEl.GetString();
if ( !Guid.TryParse( idStr, out var guid ) ) { error = "Invalid GameObject GUID"; return null; }
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null ) { error = "No active scene"; return null; }
var go = scene.Directory.FindByGuid( guid );
if ( go == null ) { error = "GameObject not found"; return null; }
foreach ( var comp in go.Components.GetAll() )
{
if ( comp.GetType().Name == typeName ) { error = null; return comp; }
}
error = $"No component '{typeName}' on the given GameObject"; return null;
}
return FindFirstComponent( typeName, out error );
}
public static System.Collections.IList GetListProperty( Component comp, string propertyName, out string error, out Type elementType )
{
elementType = null;
var prop = comp.GetType().GetProperty( propertyName );
if ( prop == null ) { error = $"Property '{propertyName}' not found on {comp.GetType().Name}"; return null; }
var val = prop.GetValue( comp );
if ( val is System.Collections.IList list )
{
if ( prop.PropertyType.IsGenericType )
elementType = prop.PropertyType.GetGenericArguments()[0];
error = null;
return list;
}
error = $"Property '{propertyName}' is not a list"; return null;
}
public static bool InvokeButton( Component comp, string buttonLabel )
{
var type = comp.GetType();
var methods = type.GetMethods( BindingFlags.Public | BindingFlags.Instance );
// Strategy 1: ButtonAttribute label match
foreach ( var method in methods )
{
if ( method.GetParameters().Length > 0 ) continue;
foreach ( var attr in method.GetCustomAttributes( true ) )
{
if ( !attr.GetType().Name.Contains( "Button" ) ) continue;
if ( AttributeStringMatches( attr, buttonLabel ) )
{
InvokeUnwrap( method, comp );
return true;
}
}
}
// Strategy 2: exact method name
foreach ( var method in methods )
{
if ( method.GetParameters().Length > 0 ) continue;
if ( method.Name == buttonLabel )
{
InvokeUnwrap( method, comp );
return true;
}
}
// Strategy 3: case-insensitive, ignore spaces
var normalized = buttonLabel.Replace( " ", "" );
foreach ( var method in methods )
{
if ( method.GetParameters().Length > 0 ) continue;
if ( string.Equals( method.Name, normalized, StringComparison.OrdinalIgnoreCase ) )
{
InvokeUnwrap( method, comp );
return true;
}
}
return false;
}
// Invoke a method and re-throw the inner exception (not the TargetInvocationException wrapper)
// so callers see the real error message instead of "Exception has been thrown by the target of an invocation."
private static void InvokeUnwrap( MethodInfo method, object target )
{
try { method.Invoke( target, null ); }
catch ( TargetInvocationException tie )
{
var inner = tie.InnerException ?? tie;
throw new Exception( $"{inner.GetType().Name}: {inner.Message}\n{inner.StackTrace}" );
}
}
private static bool AttributeStringMatches( object attr, string target )
{
var t = attr.GetType();
foreach ( var pi in t.GetProperties() )
{
if ( pi.PropertyType != typeof( string ) ) continue;
try { if ( (pi.GetValue( attr ) as string) == target ) return true; } catch { }
}
foreach ( var fi in t.GetFields() )
{
if ( fi.FieldType != typeof( string ) ) continue;
try { if ( (fi.GetValue( attr ) as string) == target ) return true; } catch { }
}
return false;
}
public static List<string> ListButtons( Component comp )
{
var labels = new List<string>();
var type = comp.GetType();
foreach ( var method in type.GetMethods( BindingFlags.Public | BindingFlags.Instance ) )
{
if ( method.GetParameters().Length > 0 ) continue;
foreach ( var attr in method.GetCustomAttributes( true ) )
{
if ( !attr.GetType().Name.Contains( "Button" ) ) continue;
var label = ExtractAttributeString( attr ) ?? method.Name;
labels.Add( label );
}
}
return labels;
}
private static string ExtractAttributeString( object attr )
{
var t = attr.GetType();
foreach ( var pi in t.GetProperties() )
{
if ( pi.PropertyType != typeof( string ) ) continue;
try { var v = pi.GetValue( attr ) as string; if ( !string.IsNullOrEmpty( v ) ) return v; } catch { }
}
foreach ( var fi in t.GetFields() )
{
if ( fi.FieldType != typeof( string ) ) continue;
try { var v = fi.GetValue( attr ) as string; if ( !string.IsNullOrEmpty( v ) ) return v; } catch { }
}
return null;
}
public static void SetMember( object obj, string memberName, object value )
{
var t = obj.GetType();
var prop = t.GetProperty( memberName );
if ( prop != null && prop.CanWrite ) { prop.SetValue( obj, ConvertValue( value, prop.PropertyType ) ); return; }
var field = t.GetField( memberName );
if ( field != null ) { field.SetValue( obj, ConvertValue( value, field.FieldType ) ); }
}
private static object ConvertValue( object value, Type target )
{
if ( value == null ) return null;
if ( target.IsAssignableFrom( value.GetType() ) ) return value;
try { return Convert.ChangeType( value, target ); } catch { return value; }
}
}
// ───────── invoke_button ─────────────────────────────────────────────────
public class InvokeButtonHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var typeName = p.TryGetProperty( "component", out var c ) ? c.GetString() : null;
var label = p.TryGetProperty( "button", out var b ) ? b.GetString() : null;
if ( string.IsNullOrEmpty( typeName ) || string.IsNullOrEmpty( label ) )
return Task.FromResult<object>( new { error = "component and button are required" } );
var comp = WorldGenHelpers.ResolveComponent( p, typeName, out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
try
{
var ok = WorldGenHelpers.InvokeButton( comp, label );
if ( !ok ) return Task.FromResult<object>( new { error = $"Button '{label}' not found on {typeName}" } );
return Task.FromResult<object>( new { invoked = true, component = typeName, button = label } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Invoke failed: {ex.Message}" } );
}
}
}
// ───────── list_component_buttons ────────────────────────────────────────
public class ListComponentButtonsHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var typeName = p.TryGetProperty( "component", out var c ) ? c.GetString() : null;
if ( string.IsNullOrEmpty( typeName ) )
return Task.FromResult<object>( new { error = "component is required" } );
var comp = WorldGenHelpers.ResolveComponent( p, typeName, out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var buttons = WorldGenHelpers.ListButtons( comp );
return Task.FromResult<object>( new { component = typeName, buttons } );
}
}
// ───────── raycast_terrain ───────────────────────────────────────────────
public class RaycastTerrainHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var x = p.TryGetProperty( "x", out var xp ) ? xp.GetSingle() : 0f;
var y = p.TryGetProperty( "y", out var yp ) ? yp.GetSingle() : 0f;
var comp = WorldGenHelpers.ResolveComponent( p, "MapBuilder", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
try
{
var sample = comp.GetType().GetMethod( "SampleHeight" );
if ( sample == null ) return Task.FromResult<object>( new { error = "SampleHeight not available on MapBuilder" } );
var height = (float)sample.Invoke( comp, new object[] { x, y } );
return Task.FromResult<object>( new { x, y, z = height } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Raycast failed: {ex.Message}" } );
}
}
}
// ───────── add_terrain_hill ──────────────────────────────────────────────
public class AddTerrainHillHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "MapBuilder", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var x = p.TryGetProperty( "x", out var xp ) ? xp.GetSingle() : 0f;
var y = p.TryGetProperty( "y", out var yp ) ? yp.GetSingle() : 0f;
var radius = p.TryGetProperty( "radius", out var rp ) ? rp.GetSingle() : 500f;
var height = p.TryGetProperty( "height", out var hp ) ? hp.GetSingle() : 100f;
var rebuild = !p.TryGetProperty( "rebuild", out var rb ) || rb.GetBoolean();
var list = WorldGenHelpers.GetListProperty( comp, "Hills", out var lerr, out var et );
if ( list == null ) return Task.FromResult<object>( new { error = lerr } );
var hill = Activator.CreateInstance( et );
WorldGenHelpers.SetMember( hill, "Position", new Vector2( x, y ) );
WorldGenHelpers.SetMember( hill, "Radius", radius );
WorldGenHelpers.SetMember( hill, "Height", height );
list.Add( hill );
if ( rebuild ) WorldGenHelpers.InvokeButton( comp, "Build Terrain" );
return Task.FromResult<object>( new { added = true, total = list.Count, rebuilt = rebuild } );
}
}
// ───────── add_terrain_clearing ──────────────────────────────────────────
public class AddTerrainClearingHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "MapBuilder", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var x = p.TryGetProperty( "x", out var xp ) ? xp.GetSingle() : 0f;
var y = p.TryGetProperty( "y", out var yp ) ? yp.GetSingle() : 0f;
var radius = p.TryGetProperty( "radius", out var rp ) ? rp.GetSingle() : 300f;
var rebuild = !p.TryGetProperty( "rebuild", out var rb ) || rb.GetBoolean();
var list = WorldGenHelpers.GetListProperty( comp, "Clearings", out var lerr, out var et );
if ( list == null ) return Task.FromResult<object>( new { error = lerr } );
var item = Activator.CreateInstance( et );
WorldGenHelpers.SetMember( item, "Position", new Vector2( x, y ) );
WorldGenHelpers.SetMember( item, "Radius", radius );
list.Add( item );
if ( rebuild ) WorldGenHelpers.InvokeButton( comp, "Build Terrain" );
return Task.FromResult<object>( new { added = true, total = list.Count, rebuilt = rebuild } );
}
}
// ───────── add_terrain_trail ─────────────────────────────────────────────
public class AddTerrainTrailHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "MapBuilder", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var fromEl = p.TryGetProperty( "from", out var fp ) ? fp : default;
var toEl = p.TryGetProperty( "to", out var tp ) ? tp : default;
if ( fromEl.ValueKind != JsonValueKind.Object || toEl.ValueKind != JsonValueKind.Object )
return Task.FromResult<object>( new { error = "from and to are required objects with x/y" } );
var from = new Vector2(
fromEl.TryGetProperty( "x", out var fx ) ? fx.GetSingle() : 0f,
fromEl.TryGetProperty( "y", out var fy ) ? fy.GetSingle() : 0f );
var to = new Vector2(
toEl.TryGetProperty( "x", out var tx ) ? tx.GetSingle() : 0f,
toEl.TryGetProperty( "y", out var ty ) ? ty.GetSingle() : 0f );
var rebuild = !p.TryGetProperty( "rebuild", out var rb ) || rb.GetBoolean();
var list = WorldGenHelpers.GetListProperty( comp, "Trails", out var lerr, out var et );
if ( list == null ) return Task.FromResult<object>( new { error = lerr } );
var item = Activator.CreateInstance( et );
WorldGenHelpers.SetMember( item, "From", from );
WorldGenHelpers.SetMember( item, "To", to );
list.Add( item );
if ( rebuild ) WorldGenHelpers.InvokeButton( comp, "Build Terrain" );
return Task.FromResult<object>( new { added = true, total = list.Count, rebuilt = rebuild } );
}
}
// ───────── clear_terrain_features ────────────────────────────────────────
public class ClearTerrainFeaturesHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "MapBuilder", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var which = p.TryGetProperty( "what", out var w ) ? w.GetString() : "all";
var rebuild = !p.TryGetProperty( "rebuild", out var rb ) || rb.GetBoolean();
var report = new Dictionary<string, int>();
string[] targets = which == "all"
? new[] { "Hills", "Clearings", "Trails", "CavePath" }
: new[] { which };
foreach ( var prop in targets )
{
var list = WorldGenHelpers.GetListProperty( comp, prop, out var lerr, out _ );
if ( list == null ) continue;
report[prop] = list.Count;
list.Clear();
}
if ( rebuild ) WorldGenHelpers.InvokeButton( comp, "Build Terrain" );
return Task.FromResult<object>( new { cleared = report, rebuilt = rebuild } );
}
}
// ───────── add_cave_waypoint ─────────────────────────────────────────────
public class AddCaveWaypointHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "CaveBuilder", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var x = p.TryGetProperty( "x", out var xp ) ? xp.GetSingle() : 0f;
var y = p.TryGetProperty( "y", out var yp ) ? yp.GetSingle() : 0f;
var z = p.TryGetProperty( "z", out var zp ) ? zp.GetSingle() : 0f;
var index = p.TryGetProperty( "index", out var ip ) ? ip.GetInt32() : -1;
var rebuild = !p.TryGetProperty( "rebuild", out var rb ) || rb.GetBoolean();
var list = WorldGenHelpers.GetListProperty( comp, "Path", out var lerr, out var et );
if ( list == null ) return Task.FromResult<object>( new { error = lerr } );
var item = Activator.CreateInstance( et );
WorldGenHelpers.SetMember( item, "Position", new Vector3( x, y, z ) );
if ( index >= 0 && index <= list.Count ) list.Insert( index, item );
else list.Add( item );
if ( rebuild ) WorldGenHelpers.InvokeButton( comp, "Build Cave" );
return Task.FromResult<object>( new { added = true, total = list.Count, rebuilt = rebuild } );
}
}
// ───────── clear_cave_path ───────────────────────────────────────────────
public class ClearCavePathHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "CaveBuilder", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var list = WorldGenHelpers.GetListProperty( comp, "Path", out var lerr, out _ );
if ( list == null ) return Task.FromResult<object>( new { error = lerr } );
var was = list.Count;
list.Clear();
WorldGenHelpers.InvokeButton( comp, "Clear Cave" );
return Task.FromResult<object>( new { cleared = was } );
}
}
// ───────── add_forest_poi ────────────────────────────────────────────────
public class AddForestPOIHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "ForestGenerator", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var name = p.TryGetProperty( "name", out var nm ) ? nm.GetString() : "POI";
var x = p.TryGetProperty( "x", out var xp ) ? xp.GetSingle() : 0f;
var y = p.TryGetProperty( "y", out var yp ) ? yp.GetSingle() : 0f;
var radius = p.TryGetProperty( "radius", out var rp ) ? rp.GetSingle() : 300f;
var density = p.TryGetProperty( "density_multiplier", out var dp ) ? dp.GetSingle() : 1f;
var rebuild = p.TryGetProperty( "rebuild", out var rb ) && rb.GetBoolean();
var list = WorldGenHelpers.GetListProperty( comp, "POIs", out var lerr, out var et );
if ( list == null ) return Task.FromResult<object>( new { error = lerr } );
var item = Activator.CreateInstance( et );
WorldGenHelpers.SetMember( item, "Name", name );
WorldGenHelpers.SetMember( item, "Position", new Vector2( x, y ) );
WorldGenHelpers.SetMember( item, "Radius", radius );
WorldGenHelpers.SetMember( item, "DensityMultiplier", density );
list.Add( item );
if ( rebuild ) WorldGenHelpers.InvokeButton( comp, "Generate Forest" );
return Task.FromResult<object>( new { added = true, index = list.Count - 1, total = list.Count, rebuilt = rebuild } );
}
}
// ───────── add_forest_trail ──────────────────────────────────────────────
public class AddForestTrailHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "ForestGenerator", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var fromIdx = p.TryGetProperty( "from_index", out var f ) ? f.GetInt32() : 0;
var toIdx = p.TryGetProperty( "to_index", out var t ) ? t.GetInt32() : 0;
var rebuild = p.TryGetProperty( "rebuild", out var rb ) && rb.GetBoolean();
var list = WorldGenHelpers.GetListProperty( comp, "Trails", out var lerr, out var et );
if ( list == null ) return Task.FromResult<object>( new { error = lerr } );
var item = Activator.CreateInstance( et );
WorldGenHelpers.SetMember( item, "FromIndex", fromIdx );
WorldGenHelpers.SetMember( item, "ToIndex", toIdx );
list.Add( item );
if ( rebuild ) WorldGenHelpers.InvokeButton( comp, "Generate Forest" );
return Task.FromResult<object>( new { added = true, total = list.Count, rebuilt = rebuild } );
}
}
// ───────── set_forest_seed ───────────────────────────────────────────────
public class SetForestSeedHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "ForestGenerator", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var seed = p.TryGetProperty( "seed", out var sp ) ? sp.GetInt32() : 77;
var rebuild = !p.TryGetProperty( "rebuild", out var rb ) || rb.GetBoolean();
var prop = comp.GetType().GetProperty( "Seed" );
if ( prop == null ) return Task.FromResult<object>( new { error = "Seed property missing" } );
prop.SetValue( comp, seed );
if ( rebuild ) WorldGenHelpers.InvokeButton( comp, "Generate Forest" );
return Task.FromResult<object>( new { set = true, seed, rebuilt = rebuild } );
}
}
// ───────── clear_forest_pois ─────────────────────────────────────────────
public class ClearForestPOIsHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "ForestGenerator", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var list = WorldGenHelpers.GetListProperty( comp, "POIs", out var lerr, out _ );
if ( list == null ) return Task.FromResult<object>( new { error = lerr } );
var was = list.Count;
list.Clear();
var trailList = WorldGenHelpers.GetListProperty( comp, "Trails", out _, out _ );
trailList?.Clear();
WorldGenHelpers.InvokeButton( comp, "Clear Forest" );
return Task.FromResult<object>( new { cleared = was } );
}
}
// ───────── sculpt_terrain ────────────────────────────────────────────────
public class SculptTerrainHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "MapBuilder", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var x = p.TryGetProperty( "x", out var xp ) ? xp.GetSingle() : 0f;
var y = p.TryGetProperty( "y", out var yp ) ? yp.GetSingle() : 0f;
var radius = p.TryGetProperty( "radius", out var rp ) ? rp.GetSingle() : 400f;
var strength = p.TryGetProperty( "strength", out var sp ) ? sp.GetSingle() : 50f;
var mode = p.TryGetProperty( "mode", out var mp ) ? mp.GetString() : "raise";
var sculpt = comp.GetType().GetMethod( "Sculpt" );
if ( sculpt == null ) return Task.FromResult<object>( new { error = "Sculpt method missing on MapBuilder" } );
try
{
var affected = (int)sculpt.Invoke( comp, new object[] { x, y, radius, strength, mode } );
return Task.FromResult<object>( new { sculpted = true, mode, affected_vertices = affected } );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Sculpt failed: {ex.Message}" } );
}
}
}
// ───────── paint_forest_density ──────────────────────────────────────────
public class PaintForestDensityHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var comp = WorldGenHelpers.ResolveComponent( p, "ForestGenerator", out var err );
if ( comp == null ) return Task.FromResult<object>( new { error = err } );
var x = p.TryGetProperty( "x", out var xp ) ? xp.GetSingle() : 0f;
var y = p.TryGetProperty( "y", out var yp ) ? yp.GetSingle() : 0f;
var radius = p.TryGetProperty( "radius", out var rp ) ? rp.GetSingle() : 800f;
var density = p.TryGetProperty( "density", out var dp ) ? dp.GetSingle() : 1f;
var rebuild = p.TryGetProperty( "rebuild", out var rb ) && rb.GetBoolean();
var list = WorldGenHelpers.GetListProperty( comp, "DensityRegions", out var lerr, out var et );
if ( list == null ) return Task.FromResult<object>( new { error = lerr } );
var item = Activator.CreateInstance( et );
WorldGenHelpers.SetMember( item, "Center", new Vector2( x, y ) );
WorldGenHelpers.SetMember( item, "Radius", radius );
WorldGenHelpers.SetMember( item, "Density", density );
list.Add( item );
if ( rebuild ) WorldGenHelpers.InvokeButton( comp, "Generate Forest" );
return Task.FromResult<object>( new { painted = true, total = list.Count, rebuilt = rebuild } );
}
}
// ───────── place_along_path ──────────────────────────────────────────────
public class PlaceAlongPathHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null ) return Task.FromResult<object>( new { error = "No active scene" } );
var modelPath = p.TryGetProperty( "model", out var mp ) ? mp.GetString() : null;
if ( string.IsNullOrEmpty( modelPath ) )
return Task.FromResult<object>( new { error = "model path is required (e.g. 'models/dev/box.vmdl')" } );
var spacing = p.TryGetProperty( "spacing", out var sp ) ? sp.GetSingle() : 200f;
var jitter = p.TryGetProperty( "jitter", out var jp ) ? jp.GetSingle() : 0f;
var minScale = p.TryGetProperty( "min_scale", out var mnp ) ? mnp.GetSingle() : 1f;
var maxScale = p.TryGetProperty( "max_scale", out var mxp ) ? mxp.GetSingle() : 1f;
var seed = p.TryGetProperty( "seed", out var sdp ) ? sdp.GetInt32() : 42;
var name = p.TryGetProperty( "name", out var np ) ? np.GetString() : "PathItem";
if ( !p.TryGetProperty( "points", out var pointsEl ) || pointsEl.ValueKind != JsonValueKind.Array )
return Task.FromResult<object>( new { error = "points must be an array of {x,y,z}" } );
var points = new List<Vector3>();
foreach ( var pt in pointsEl.EnumerateArray() )
{
points.Add( new Vector3(
pt.TryGetProperty( "x", out var px ) ? px.GetSingle() : 0f,
pt.TryGetProperty( "y", out var py ) ? py.GetSingle() : 0f,
pt.TryGetProperty( "z", out var pz ) ? pz.GetSingle() : 0f ) );
}
if ( points.Count < 2 ) return Task.FromResult<object>( new { error = "need at least 2 points" } );
Model model;
try { model = Model.Load( modelPath ); }
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"Could not load model '{modelPath}': {ex.Message}" } ); }
var rng = new Random( seed );
var folder = scene.CreateObject( true );
folder.Name = $"== {name}s ==";
int placed = 0;
for ( int i = 0; i < points.Count - 1; i++ )
{
var from = points[i];
var to = points[i + 1];
var seg = to - from;
var len = seg.Length;
if ( len < 0.01f ) continue;
var dir = seg / len;
var steps = Math.Max( 1, (int)(len / spacing) );
for ( int s = 0; s <= steps; s++ )
{
var t = (float)s / steps;
var basePos = from + seg * t;
var jx = (float)(rng.NextDouble() * 2 - 1) * jitter;
var jy = (float)(rng.NextDouble() * 2 - 1) * jitter;
var pos = basePos + new Vector3( jx, jy, 0 );
var go = scene.CreateObject( true );
go.Name = $"{name} {++placed}";
go.SetParent( folder );
go.WorldPosition = pos;
go.WorldRotation = Rotation.FromYaw( (float)(rng.NextDouble() * 360.0) );
var scale = MathX.Lerp( minScale, maxScale, (float)rng.NextDouble() );
go.WorldScale = new Vector3( scale );
var renderer = go.AddComponent<ModelRenderer>();
renderer.Model = model;
}
}
return Task.FromResult<object>( new { placed, folder = folder.Id.ToString() } );
}
}
// ════════════════════════════════════════════════════════════════════════
// Coding / type discovery handlers (Batch 16)
// ════════════════════════════════════════════════════════════════════════
// ───────── describe_type ─────────────────────────────────────────────────
public class DescribeTypeHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var name = p.TryGetProperty( "name", out var n ) ? n.GetString() : null;
if ( string.IsNullOrEmpty( name ) ) return Task.FromResult<object>( new { error = "name is required" } );
var typeDesc = Game.TypeLibrary.GetType( name );
Type targetType = typeDesc?.TargetType;
if ( targetType == null )
{
foreach ( var asm in AppDomain.CurrentDomain.GetAssemblies() )
{
try
{
foreach ( var t in asm.GetTypes() )
{
if ( t.Name == name || t.FullName == name ) { targetType = t; break; }
}
}
catch { }
if ( targetType != null ) break;
}
}
if ( targetType == null ) return Task.FromResult<object>( new { error = $"Type '{name}' not found" } );
var properties = new List<object>();
foreach ( var pi in targetType.GetProperties( BindingFlags.Public | BindingFlags.Instance ) )
{
properties.Add( new
{
name = pi.Name,
type = pi.PropertyType.Name,
canRead = pi.CanRead,
canWrite = pi.CanWrite
} );
}
var methods = new List<object>();
foreach ( var m in targetType.GetMethods( BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static ).Take( 80 ) )
{
if ( m.IsSpecialName ) continue;
var pars = string.Join( ", ", m.GetParameters().Select( pp => $"{pp.ParameterType.Name} {pp.Name}" ) );
methods.Add( new
{
name = m.Name,
returns = m.ReturnType.Name,
signature = $"{m.ReturnType.Name} {m.Name}({pars})",
isStatic = m.IsStatic
} );
}
var events = targetType.GetEvents( BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static )
.Select( e => new { name = e.Name, type = e.EventHandlerType?.Name } ).ToList();
var attrs = targetType.GetCustomAttributes( false ).Select( a => a.GetType().Name ).ToList();
return Task.FromResult<object>( new
{
name = targetType.Name,
fullName = targetType.FullName,
baseType = targetType.BaseType?.Name,
isAbstract = targetType.IsAbstract,
isComponent = typeof( Component ).IsAssignableFrom( targetType ),
properties,
methods,
events,
attributes = attrs
} );
}
}
// ───────── search_types ──────────────────────────────────────────────────
public class SearchTypesHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var pattern = p.TryGetProperty( "pattern", out var pat ) ? pat.GetString() : "";
var ns = p.TryGetProperty( "namespace", out var nsp ) ? nsp.GetString() : null;
var componentsOnly = p.TryGetProperty( "components_only", out var co ) && co.GetBoolean();
var limit = p.TryGetProperty( "limit", out var lp ) ? lp.GetInt32() : 50;
var matches = new List<object>();
var compType = typeof( Component );
foreach ( var asm in AppDomain.CurrentDomain.GetAssemblies() )
{
try
{
foreach ( var t in asm.GetTypes() )
{
if ( !t.IsPublic ) continue;
if ( componentsOnly && !compType.IsAssignableFrom( t ) ) continue;
if ( !string.IsNullOrEmpty( ns ) && (t.Namespace == null || !t.Namespace.Contains( ns, StringComparison.OrdinalIgnoreCase )) ) continue;
if ( !string.IsNullOrEmpty( pattern ) && !t.Name.Contains( pattern, StringComparison.OrdinalIgnoreCase ) ) continue;
matches.Add( new
{
name = t.Name,
fullName = t.FullName,
isComponent = compType.IsAssignableFrom( t ),
isAbstract = t.IsAbstract
} );
if ( matches.Count >= limit ) break;
}
}
catch { }
if ( matches.Count >= limit ) break;
}
return Task.FromResult<object>( new { count = matches.Count, matches } );
}
}
// ───────── get_method_signature ──────────────────────────────────────────
public class GetMethodSignatureHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var typeName = p.TryGetProperty( "type", out var tp ) ? tp.GetString() : null;
var methodName = p.TryGetProperty( "method", out var mp ) ? mp.GetString() : null;
if ( string.IsNullOrEmpty( typeName ) || string.IsNullOrEmpty( methodName ) )
return Task.FromResult<object>( new { error = "type and method are required" } );
Type targetType = Game.TypeLibrary.GetType( typeName )?.TargetType;
if ( targetType == null )
{
foreach ( var asm in AppDomain.CurrentDomain.GetAssemblies() )
{
try { foreach ( var t in asm.GetTypes() ) if ( t.Name == typeName ) { targetType = t; break; } } catch { }
if ( targetType != null ) break;
}
}
if ( targetType == null ) return Task.FromResult<object>( new { error = $"Type '{typeName}' not found" } );
var overloads = new List<object>();
foreach ( var m in targetType.GetMethods( BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static ) )
{
if ( m.Name != methodName ) continue;
var pars = m.GetParameters().Select( par => new { name = par.Name, type = par.ParameterType.Name, hasDefault = par.HasDefaultValue, defaultValue = par.HasDefaultValue ? par.DefaultValue?.ToString() : null } ).ToArray();
overloads.Add( new
{
returns = m.ReturnType.Name,
signature = $"{m.ReturnType.Name} {m.Name}({string.Join( ", ", pars.Select( x => $"{x.type} {x.name}" ) )})",
parameters = pars,
isStatic = m.IsStatic
} );
}
if ( overloads.Count == 0 ) return Task.FromResult<object>( new { error = $"Method '{methodName}' not found on '{typeName}'" } );
return Task.FromResult<object>( new { type = typeName, method = methodName, overloads } );
}
}
// ───────── find_in_project ───────────────────────────────────────────────
public class FindInProjectHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var symbol = p.TryGetProperty( "symbol", out var sp ) ? sp.GetString() : null;
if ( string.IsNullOrEmpty( symbol ) ) return Task.FromResult<object>( new { error = "symbol is required" } );
var ext = p.TryGetProperty( "extension", out var ep ) ? ep.GetString() : ".cs";
var maxResults = p.TryGetProperty( "max_results", out var mp ) ? mp.GetInt32() : 25;
var root = Project.Current?.GetRootPath();
if ( string.IsNullOrEmpty( root ) || !Directory.Exists( root ) )
return Task.FromResult<object>( new { error = "Project root not found" } );
var hits = new List<object>();
try
{
foreach ( var file in Directory.EnumerateFiles( root, "*" + ext, SearchOption.AllDirectories ) )
{
if ( hits.Count >= maxResults ) break;
if ( file.Contains( $"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}" ) ) continue;
if ( file.Contains( $"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}" ) ) continue;
if ( file.Contains( $"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}" ) ) continue;
try
{
var lines = File.ReadAllLines( file );
for ( int i = 0; i < lines.Length; i++ )
{
if ( lines[i].Contains( symbol, StringComparison.Ordinal ) )
{
hits.Add( new
{
file = file.Substring( root.Length ).TrimStart( Path.DirectorySeparatorChar ),
line = i + 1,
text = lines[i].Trim()
} );
if ( hits.Count >= maxResults ) break;
}
}
}
catch { }
}
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Search failed: {ex.Message}" } );
}
return Task.FromResult<object>( new { symbol, count = hits.Count, results = hits } );
}
}