Editor/WorkflowHandlers.cs

Editor bridge workflow handlers for scene checkpoints, scene description, and code scaffolds. Implements checkpoint_scene (save scene roots to temp JSON), restore_checkpoint (replace scene contents from JSON), list_checkpoints (enumerate saved snapshots), describe_scene (collect component/tag/camera/light/bounds histograms), and scaffolding handlers create_team_assigner and create_idle_income which write C# scaffold code files.

File AccessReflectionNetworking
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;

// ═══════════════════════════════════════════════════════════════════
// Batch 53 — bridge_workflow + scene understanding + Tier-2 scaffolds (wave 3)
//   checkpoint_scene / restore_checkpoint / list_checkpoints — scene snapshots
//     to temp storage: the agent-side safety net for the engine's inert undo.
//   describe_scene — one-call scene orientation (histograms, cameras, bounds).
//   create_team_assigner / create_idle_income — corpus Tier-2 scaffolds.
// ═══════════════════════════════════════════════════════════════════

internal static class SceneCheckpoints
{
	internal static string Dir()
	{
		var ident = Project.Current?.Config?.Ident ?? "unknown";
		var dir = Path.Combine( Path.GetTempPath(), "sbox-bridge-checkpoints", ident );
		Directory.CreateDirectory( dir );
		return dir;
	}
}

/// <summary>checkpoint_scene — snapshot every root GameObject to a temp file.</summary>
public class CheckpointSceneHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		if ( Game.IsPlaying )
			return Task.FromResult<object>( new { error = "Checkpoints capture the EDIT scene — stop play first (stop_play), or runtime state would poison the snapshot." } );

		var scene = SceneEditorSession.Active?.Scene;
		if ( scene == null )
			return Task.FromResult<object>( new { error = "No active scene" } );

		var label = p.TryGetProperty( "label", out var l ) && !string.IsNullOrWhiteSpace( l.GetString() )
			? string.Concat( l.GetString().Where( c => char.IsLetterOrDigit( c ) || c == '-' || c == '_' ) )
			: "checkpoint";

		try
		{
			var roots = new System.Text.Json.Nodes.JsonArray();
			int objectCount = 0;
			var options = new GameObject.SerializeOptions();
			foreach ( var root in scene.Children.ToArray() )
			{
				if ( root == null ) continue;
				roots.Add( root.Serialize( options ) );
				objectCount++;
			}

			var id = $"cp_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{label}";
			var file = new System.Text.Json.Nodes.JsonObject
			{
				["scene"] = scene.Name,
				["label"] = label,
				["createdUtc"] = DateTime.UtcNow.ToString( "o" ),
				["roots"] = roots
			};
			var path = Path.Combine( SceneCheckpoints.Dir(), id + ".json" );
			File.WriteAllText( path, JsonSerializer.Serialize( file, new JsonSerializerOptions { WriteIndented = false } ) );

			return Task.FromResult<object>( new
			{
				checkpointed = true,
				id,
				label,
				scene = scene.Name,
				rootObjects = objectCount,
				sizeBytes = new FileInfo( path ).Length,
				note = "Snapshot stored OUTSIDE the project (temp dir; survives editor restarts, not OS cleanup). Roll back with restore_checkpoint; browse with list_checkpoints."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"checkpoint_scene failed: {ex.Message}" } );
		}
	}
}

/// <summary>restore_checkpoint — REPLACE the open scene's contents with a snapshot.</summary>
public class RestoreCheckpointHandler : 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.TryGetProperty( "id", out var idEl ) ? idEl.GetString() : null;
		if ( string.IsNullOrWhiteSpace( id ) )
			return Task.FromResult<object>( new { error = "id (checkpoint id from checkpoint_scene / list_checkpoints) is required — restore is destructive and never guesses" } );

		var path = Path.Combine( SceneCheckpoints.Dir(), id + ".json" );
		if ( !File.Exists( path ) )
			return Task.FromResult<object>( new { error = $"Checkpoint not found: {id}. See list_checkpoints." } );

		try
		{
			var node = System.Text.Json.Nodes.JsonNode.Parse( File.ReadAllText( path ) );
			var roots = node?["roots"]?.AsArray();
			if ( roots == null )
				return Task.FromResult<object>( new { error = "Checkpoint file has no roots array — corrupt?" } );

			// Full replace: destroy every current root, then rebuild from the snapshot.
			// Original guids are preserved (no collisions after the clear), so component
			// cross-references inside the snapshot stay wired.
			int destroyed = 0;
			foreach ( var child in scene.Children.ToArray() )
			{
				if ( child == null ) continue;
				child.Destroy();
				destroyed++;
			}

			int restored = 0;
			foreach ( var rootNode in roots )
			{
				if ( rootNode is not System.Text.Json.Nodes.JsonObject obj ) continue;
				var go = scene.CreateObject( true );
				go.Deserialize( obj );
				restored++;
			}

			return Task.FromResult<object>( new
			{
				restored = true,
				id,
				destroyedRoots = destroyed,
				restoredRoots = restored,
				note = "Scene contents replaced from the snapshot. The scene FILE on disk is untouched until save_scene."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"restore_checkpoint failed: {ex.Message}" } );
		}
	}
}

/// <summary>list_checkpoints — enumerate stored scene snapshots for this project.</summary>
public class ListCheckpointsHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var entries = Directory.GetFiles( SceneCheckpoints.Dir(), "cp_*.json" )
				.Select( f => new FileInfo( f ) )
				.OrderByDescending( f => f.LastWriteTimeUtc )
				.Select( f => new
				{
					id = Path.GetFileNameWithoutExtension( f.Name ),
					createdUtc = f.LastWriteTimeUtc.ToString( "o" ),
					sizeBytes = f.Length
				} )
				.ToArray();

			return Task.FromResult<object>( new
			{
				total = entries.Length,
				checkpoints = entries,
				note = entries.Length == 0
					? "No checkpoints yet — create one with checkpoint_scene before risky batch edits."
					: "Pass an id to restore_checkpoint to roll the scene back."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"list_checkpoints failed: {ex.Message}" } );
		}
	}
}

/// <summary>describe_scene — one-call scene orientation: composition, cameras, lights, bounds, tags.</summary>
public class DescribeSceneHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		var playing = Game.IsPlaying;
		var scene = playing ? Game.ActiveScene : SceneEditorSession.Active?.Scene;
		if ( scene == null )
			return Task.FromResult<object>( new { error = "No active scene" } );

		try
		{
			var componentCounts = new Dictionary<string, int>();
			var tagCounts = new Dictionary<string, int>();
			var cameras = new List<object>();
			int lightCount = 0, total = 0;
			bool hasBounds = false;
			Vector3 mins = default, maxs = default;

			foreach ( var go in scene.GetAllObjects( true ) )
			{
				if ( go == null ) continue;
				total++;

				foreach ( var tag in go.Tags.TryGetAll() )
					tagCounts[tag] = tagCounts.GetValueOrDefault( tag ) + 1;

				foreach ( var comp in go.Components.GetAll() )
				{
					if ( comp == null ) continue;
					var typeName = comp.GetType().Name;
					componentCounts[typeName] = componentCounts.GetValueOrDefault( typeName ) + 1;

					if ( comp is CameraComponent )
						cameras.Add( new { id = go.Id.ToString(), name = go.Name, position = go.WorldPosition.ToString() } );
					else if ( typeName.Contains( "Light", StringComparison.OrdinalIgnoreCase ) )
						lightCount++;
				}

				// Aggregate world bounds from renderable objects only (empty bounds skew the box).
				var box = go.GetBounds();
				if ( box.Size.Length > 0.01f )
				{
					if ( !hasBounds ) { mins = box.Mins; maxs = box.Maxs; hasBounds = true; }
					else { mins = Vector3.Min( mins, box.Mins ); maxs = Vector3.Max( maxs, box.Maxs ); }
				}
			}

			return Task.FromResult<object>( new
			{
				scene = scene.Name,
				playing,
				totalObjects = total,
				rootObjects = scene.Children.Count,
				componentHistogram = componentCounts.OrderByDescending( kv => kv.Value ).Take( 20 )
					.ToDictionary( kv => kv.Key, kv => kv.Value ),
				cameras,
				lightCount,
				tags = tagCounts.OrderByDescending( kv => kv.Value ).Take( 12 )
					.ToDictionary( kv => kv.Key, kv => kv.Value ),
				contentBounds = hasBounds
					? new { mins = mins.ToString(), maxs = maxs.ToString(), size = ( maxs - mins ).ToString() }
					: null,
				note = "Drill down: find_objects by component/tag, get_scene_hierarchy for structure, find_broken_references for health, screenshot_orbit to look at something."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"describe_scene failed: {ex.Message}" } );
		}
	}
}

/// <summary>create_team_assigner — scaffold a host-authoritative balanced team assigner.</summary>
public class CreateTeamAssignerHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "TeamAssigner", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			var teams = p.TryGetProperty( "teams", out var t ) && t.ValueKind == JsonValueKind.Array
				? t.EnumerateArray().Select( e => e.GetString() ).Where( s => !string.IsNullOrWhiteSpace( s ) ).ToArray()
				: new[] { "Red", "Blue" };
			if ( teams.Length < 2 ) teams = new[] { "Red", "Blue" };

			var code = BuildCode( className, teams );
			ScaffoldHelpers.WriteCode( fullPath, code );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				teams,
				nextSteps = new[]
				{
					"trigger_hotload, then check compile_status",
					$"Attach {className} to your game manager object",
					$"Call {className}.Instance.AssignSmallest(steamId) from your join hook (e.g. INetworkListener.OnActive)",
					$"Subscribe to {className}.OnTeamAssigned for HUD/spawn logic"
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_team_assigner failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className, string[] teams )
	{
		var teamList = string.Join( ", ", teams.Select( t => $"\"{t}\"" ) );
		return $@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// {className} — host-authoritative balanced team assignment (smallest-bucket draft).
///
/// The HOST owns the roster: call AssignSmallest(steamId) when a player joins
/// (e.g. from INetworkListener.OnActive) and it drops them into the emptiest
/// team, announces via [Rpc.Broadcast] so every client's mirror agrees, and
/// fires the static OnTeamAssigned. Rebalance() redrafts everyone smallest-first.
/// Single-player safe (assigns team 0).
/// </summary>
public sealed class {className} : Component
{{
	public static {className} Instance {{ get; private set; }}

	[Property] public List<string> TeamNames {{ get; set; }} = new() {{ {teamList} }};

	/// <summary>steamId → team index. Host-written, client-mirrored via the broadcast.</summary>
	readonly Dictionary<ulong, int> _roster = new();

	/// <summary>(steamId, teamIndex, teamName) — fires on host and all clients.</summary>
	public static event Action<ulong, int, string> OnTeamAssigned;

	protected override void OnEnabled()
	{{
		Instance = this;
	}}

	protected override void OnDisabled()
	{{
		if ( Instance == this ) Instance = null;
	}}

	/// <summary>Host-only: put a player on the emptiest team. Returns the team index.</summary>
	public int AssignSmallest( ulong steamId )
	{{
		if ( IsProxy ) return GetTeam( steamId );

		var counts = new int[TeamNames.Count];
		foreach ( var kv in _roster )
			if ( kv.Key != steamId && kv.Value >= 0 && kv.Value < counts.Length )
				counts[kv.Value]++;

		int smallest = 0;
		for ( int i = 1; i < counts.Length; i++ )
			if ( counts[i] < counts[smallest] ) smallest = i;

		AnnounceAssignment( steamId, smallest );
		return smallest;
	}}

	/// <summary>Host-only: redraft every known player smallest-first (stable order).</summary>
	public void Rebalance()
	{{
		if ( IsProxy ) return;
		var players = _roster.Keys.ToArray();
		_roster.Clear();
		foreach ( var id in players )
			AssignSmallest( id );
	}}

	/// <summary>Team index for a player, or -1 if unassigned.</summary>
	public int GetTeam( ulong steamId )
		=> _roster.TryGetValue( steamId, out var team ) ? team : -1;

	/// <summary>All players on a team.</summary>
	public IEnumerable<ulong> GetMembers( int teamIndex )
		=> _roster.Where( kv => kv.Value == teamIndex ).Select( kv => kv.Key );

	[Rpc.Broadcast]
	void AnnounceAssignment( ulong steamId, int teamIndex )
	{{
		_roster[steamId] = teamIndex;
		var name = teamIndex >= 0 && teamIndex < TeamNames.Count ? TeamNames[teamIndex] : $""Team {{teamIndex}}"";
		OnTeamAssigned?.Invoke( steamId, teamIndex, name );
	}}
}}
";
	}
}

/// <summary>create_idle_income — scaffold a host-authoritative passive income ticker.</summary>
public class CreateIdleIncomeHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "IdleIncome", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			float perTick = p.TryGetProperty( "incomePerTick", out var it ) && it.TryGetSingle( out var itf ) ? itf : 1f;
			float tickSeconds = p.TryGetProperty( "tickSeconds", out var ts ) && ts.TryGetSingle( out var tsf ) ? tsf : 1f;

			var code = BuildCode( className, perTick, tickSeconds );
			ScaffoldHelpers.WriteCode( fullPath, code );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				nextSteps = new[]
				{
					"trigger_hotload, then check compile_status",
					$"Attach {className} next to your wallet component (create_economy_wallet) — it auto-wires any sibling with an AddMoney(int) method",
					"Pair with create_offline_progress for away-time earnings — the complete idle kit"
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_idle_income failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className, float perTick, float tickSeconds )
	{
		var ci = System.Globalization.CultureInfo.InvariantCulture;
		return $@"using Sandbox;
using System;
using System.Linq;

/// <summary>
/// {className} — host-authoritative passive income: every TickSeconds the host
/// grants IncomePerTick × Multiplier. Auto-wires a sibling wallet: the first
/// component on this GameObject with an AddMoney(int) method (e.g. a
/// create_economy_wallet scaffold) receives the grant; otherwise override
/// Grant(). TotalEarned replicates to clients. Pair with create_offline_progress
/// for away-time earnings. Single-player safe.
/// </summary>
public class {className} : Component
{{
	[Property] public float IncomePerTick {{ get; set; }} = {perTick.ToString( ci )}f;
	[Property] public float TickSeconds {{ get; set; }} = {tickSeconds.ToString( ci )}f;
	[Property] public float Multiplier {{ get; set; }} = 1f;
	[Property] public bool Paused {{ get; set; }}

	[Sync( SyncFlags.FromHost )] public float TotalEarned {{ get; set; }}

	/// <summary>(amount granted this tick, new TotalEarned) — host only.</summary>
	public static event Action<float, float> OnIncomeTick;

	TimeUntil _nextTick;

	protected override void OnStart()
	{{
		_nextTick = TickSeconds;
	}}

	protected override void OnFixedUpdate()
	{{
		if ( IsProxy || Paused ) return;
		if ( !_nextTick ) return;
		_nextTick = TickSeconds;

		var amount = IncomePerTick * Multiplier;
		if ( amount <= 0f ) return;

		TotalEarned += amount;
		Grant( amount );
		OnIncomeTick?.Invoke( amount, TotalEarned );
	}}

	/// <summary>
	/// Deliver the income. Default: invoke AddMoney(int) on the first sibling
	/// component that has one (reflection via TypeLibrary — no compile-time
	/// dependency on the wallet type). Override for custom delivery.
	/// </summary>
	protected virtual void Grant( float amount )
	{{
		foreach ( var comp in Components.GetAll() )
		{{
			if ( comp == this || comp is null ) continue;
			var type = Game.TypeLibrary?.GetType( comp.GetType() );
			var method = type?.Methods?.FirstOrDefault( m => m.Name == ""AddMoney"" );
			if ( method == null ) continue;
			try
			{{
				method.Invoke( comp, new object[] {{ (int)amount }} );
				return;
			}}
			catch {{ /* wrong signature — keep looking */ }}
		}}
		// No wallet sibling found — TotalEarned still accumulates; read it directly
		// or override Grant() to route income where you need it.
	}}
}}
";
	}
}